diff --git a/CODEBUDDY.md b/CODEBUDDY.md index 8f15c80..47caed4 100644 --- a/CODEBUDDY.md +++ b/CODEBUDDY.md @@ -50,6 +50,7 @@ python verify_mcp_tcp_hardening.py # TCP 收发路径加固(超长帧 / 粘 python verify_mcp_fault_injection.py # 工具异常保护(类型错误不再崩 CAD) python verify_stdio_bridge.py # stdio ↔ TCP 桥(协议协商 / 转发 / 通知) python verify_annotate.py # 批注回写链路 +python verify_drawing_area.py # 绘图区最大化:基线 → max → restore 全回位(实测 3.68x) ``` 这些脚本通过 TCP 连接 `127.0.0.1:8080` 收发 JSON-RPC。 @@ -90,7 +91,7 @@ python verify_annotate.py # 批注回写链路 ### 配置与产物布局 -- 运行时配置:`mcp_config.json`,必须与 `.arx` 同目录。示例见 `bin/Debug-R230/mcp_config.json`(含 `zoom_window_normalized`、`zoom_extents`、`get_viewport_screenshot` 三个工具);`src/cad_mcp_frame/cad-mcp-frame.json` 是模板(文件名带连字符,非运行时文件)。 +- 运行时配置:`mcp_config.json`,必须与 `.arx` 同目录。示例见 `bin/Debug-R230/mcp_config.json`(当前注册 27 个工具,含 `zoom_window_normalized`、`zoom_extents`、`get_viewport_screenshot`、`set_drawing_area` 等);`src/cad_mcp_frame/cad-mcp-frame.json` 是模板(文件名带连字符,非运行时文件)。 - `global_settings.plugin_directory` 指向 C++ DLL 目录,`lisp_directory` 指向 LISP 目录(默认都相对模块目录)。示例 LISP 见 `bin/Debug-R230/mcp_plugins/mcp_table_array.lsp`。 - `cmake/functions.cmake` 提供工程级封装:`target_link_objectarx`(自动按 ZWCAD 与否链接 `ThirdParty::ZRX`/`ThirdParty::ARX`、注入 `ARX`/`SUB_ARX` 宏)、`target_set_name_and_suffix`(`.arx` 在 ZWCAD 下自动变 `.zrx`)、`add_def_file_name`、`set_debug_post_build` 等。新增目标应复用这些函数而非手写链接。 - `cmake/ThirdPartyDeps.cmake` 注册所有 `ThirdParty::*` INTERFACE 目标(ARX/ZRX/cURL/SQLite3/BitAnswer/modernGlue/CadOcr)。 diff --git a/ai.memory/MEMORY.md b/ai.memory/MEMORY.md index 883805e..f8b0f36 100644 --- a/ai.memory/MEMORY.md +++ b/ai.memory/MEMORY.md @@ -11,3 +11,6 @@ - [MCP 基座 c++_dll 加载与执行的坑](project_mcp_frame_dll_loader.md) — acrxGetApiVersion 有状态不可比对、写库需文档锁、同一 DLL 只加载一次 - [用户协作偏好](feedback_preferences.md) — 调试用 Debug/R230、模型 deepseek-flash、DLL 集中在 cad_mcp_plugins 等 - [环境与外部资源](reference_environment.md) — 构建/运行要点、CAD SDK 路径、测试脚本 +- [工具需求格式](tool_request_format.md) — 现有工具不够用时的结构化「工具需求」模板(面向 AI 自己实现) +- [Agent 路线与多源架构](project_agent_roadmap.md) — 总目标(多格式图纸)、视觉为最大公约数、第一步多源证据层+多图、后续路线 +- [工具层与 agent 层的正交分工](feedback_tool_layering.md) — 反对无休止加工具;工具层正交稳定,业务规则进 agent 层 diff --git a/ai.memory/feedback_tool_layering.md b/ai.memory/feedback_tool_layering.md new file mode 100644 index 0000000..bc4fc8f --- /dev/null +++ b/ai.memory/feedback_tool_layering.md @@ -0,0 +1,25 @@ +--- +name: 工具层与 agent 层的正交分工 +description: 用户反对"无休止加工具"堆业务能力;工具层应正交稳定,业务规则进 agent 层 +type: feedback +--- + +# 工具层与 agent 层的正交分工 + +**规则**:不要靠"无休止加工具"堆业务能力。判断一个需求该加工具、还是该在 agent 层做: + +> 能由已有工具的**原始事实**组合出来的结论 → **agent 层(Python)**做; +> 只能由 **CAD 侧**提供的原始事实/动作 → 才加工具,且必须**通用正交**。 + +**Why**:用户(2026-10-15)明确指出"需要的工具是动态的、不确定的,根据具体业务出现, +无休止添加工具肯定不对"。工具层应**稳定正交**,业务层**高频可变**——业务变了只改 agent +侧清单/规则,不碰工具、不重编 DLL。 + +**How to apply**: + +- 业务规则(如"法兰数 == 2×阀门数"、管线连通性)→ 写进 agent 层 `checks.py`, + **绝不做成工具**。 +- 通用感官(如读任意实体几何 `get_entity_geometry`)→ 该补,且要设计成对**任何图纸**通用, + 不为某个业务/格式定制(属"正交感官",是 agent 迭代的前提)。 +- 只有 **CAD 进程内**才能提供的能力才考虑加 MCP 工具;PDF/图片等应在 agent 侧处理。 +- 这是"B 最小集"思想的延续:追求**最小正交基**,而非功能全集。 diff --git a/ai.memory/project_agent_roadmap.md b/ai.memory/project_agent_roadmap.md new file mode 100644 index 0000000..f032369 --- /dev/null +++ b/ai.memory/project_agent_roadmap.md @@ -0,0 +1,173 @@ +--- +name: Agent 路线与多源架构(2026-10-15) +description: dwg_review agent 的总目标、多源图纸分层架构(视觉为最大公约数)、工具层与 agent 层的正交分工、多源证据层、截图去 UI、grounding 实测、热力图 + 自适应瓦片规划 + VLM 分辨率标定、绘图区最大化(set_drawing_area)与空瓦片过滤、后续路线 +type: 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 主动放大(自适应取证)**:复用受控只读取证子循环,让 VLM 请求放大看不清处。 + - 两条路径:① 按实体 `zoom_to_entity(handle)`(可靠);② 按区域(把 VLM 归一化坐标映射成 + WCS)——**grounding 已实测可用**(见上节)。 + - 必须设**迭代上限 + 兜底**;**不把"看清没"完全交给 VLM**(会过度自信/幻觉)。 + - 现有 `zoom_to_window`/`zoom_to_entity`/`get_viewport_screenshot` 已在只读白名单, + **无需新增工具**。 +2. **PDF / 图片源**:在 `phases/evidence.collect()` 加分支,产出**同形** evidence + (`window=None`、结构化空信封、`images` 带 label);规划阶段抽象"审查单元" + (CAD=图框 / PDF=页 / 图片=单);批注对非 CAD 源降级(`annotator` 已天然跳过无 handle 的 issue)。 +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)**本步不用**,留作后续**几何相交/干涉检查**引擎。 + 其按质心中位分割 → 节点框**可能重叠**,不适合铺屏瓦片;瓦片切分用四叉树/网格(空间不相交)。 +- 标定判据:**同时测「读出文字」与「分辨线细节」**;标定字高 **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[]`(现 **27** 个),工厂 `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 延迟还原**)。 diff --git a/ai.memory/project_review_agent_impl.md b/ai.memory/project_review_agent_impl.md index 0036cd2..e76fd34 100644 --- a/ai.memory/project_review_agent_impl.md +++ b/ai.memory/project_review_agent_impl.md @@ -97,6 +97,7 @@ type: project `mcp_Tools::clear()`(唯一 `FreeLibrary` 处)仅在 ARX 卸载时调用 → **没有热重载**。 流程:关 CAD → `cmake --build --preset R230 --config Debug` → 重启 `acad.exe`(`acad.rx` 自动加载) → 轮询 `127.0.0.1:8080` 监听。编译阶段可先行(语法错误会被提前暴露,只有链接会因占用失败)。 + **用户已授权(2026-10-15):遇到 LNK1168 直接 `Stop-Process acad -Force` 即可,不必先问。** ## 基座健壮性:工具执行的异常安全(2026-10-14) @@ -139,3 +140,26 @@ DLL 代理写锁改为 `ScopedDocumentWriteLock` RAII)。**插件作者无需 `-32602/-32600/-32700`;工具内部类型异常被兜为 `result.isError=true`;反复注入 10 次后 CAD 存活、写锁未泄漏)。回归 `verify_mcp_frame` 4/4、`verify_stdio_bridge` 24/24、 `verify_annotate` 9/9、`dwg_review --check` 均正常。 + +## Plant 3D 图纸实体语义与圈注(2026-10-14 实机确认) + +**这是 Plant 3D / AEC 自定义实体图,插件无法内省**——`get_entity_detail` 对它们返回 +**空 `properties`**,`query_entities` 也只给 `bbox`/`layer`/`type`。类型语义: + +- `AECDBEWPIPE` = 管道(一根管子一个实体)。 +- `AECDBEWFITTING` = 管件,**两种形态靠 bbox 长宽比区分**: + - 近正方(aspect = min/max ≥ 0.5,约 250×250)= **弯头**; + - 细长条(aspect < 0.5,约 20×220)= **法兰**。 +- `AECDBEWEQUIPMENT` = 设备,本图中即**阀门**(蝶阀符号)。 + +**不变量(交叉校验用)**:法兰数 == 2 × 阀门数(阀门两侧各一法兰)。 + +**How to apply(识别/统计这类图)**:不要依赖属性,改用「`query_entities(type_filter=...)` +取 bbox + 几何分类 + 不变量交叉校验 + `get_viewport_screenshot` 视觉复核」。图框用 +`detect_drawing_frames`(本图返回 3 个块图框 A0/A1/A2,`kind=block`);把实体按「中心点落在 +哪个图框 bbox 内」归类到框。 + +**圈注配方(已验证)**:`draw_revision_cloud {shape:"circle", center:[cx,cy], +radius:max_side*1.1, layer:"AI_REVIEW", color:3}`——center 取阀门 bbox 中心,半径取包围盒 +**长边 × 1.1**(本图阀门 bbox ≈230×220 → r≈253),绿色云线正好套住蝶阀符号。逐框 +`zoom_to_window(frame_bbox)` + 截图可验证。画前若要重来,`delete_entities` 按 handle 删旧圆。 diff --git a/ai.memory/project_stdio_bridge.md b/ai.memory/project_stdio_bridge.md index a134ecc..fe68da6 100644 --- a/ai.memory/project_stdio_bridge.md +++ b/ai.memory/project_stdio_bridge.md @@ -6,8 +6,18 @@ type: project # stdio ↔ TCP 桥(`mcp_bridge/`) -**状态:已实现并验证通过**(2026-10-14)。这是「MCP 标准化」路线的第二步 -(第一步是基座 B 最小集,见 `project_review_agent_impl.md`)。 +**状态:已实现并验证通过**(2026-10-14),且**已注册进 CodeBuddy**(2026-10-14)。 +这是「MCP 标准化」路线的第二步(第一步是基座 B 最小集,见 `project_review_agent_impl.md`)。 + +## 已接入 CodeBuddy(用户级) + +配置写在 `C:\Users\gjm\.codebuddy\.mcp.json`(user 作用域、推荐路径、免首连审批、 +不污染仓库)——服务名 `cad-autocad`,`type: stdio`,`command` = 本机 Python314, +`args: ["-m","mcp_bridge"]`,`env` 带 `PYTHONPATH=D:\new-start\CadProject` + +`CAD_MCP_HOST/PORT`。**改配置后需重启 CodeBuddy 才生效**;工具以 `mcp__cad-autocad__<工具名>` +形式出现(26 个)。注意:同目录另有一个**空的**旧文件 `~/.codebuddy/mcp.json`(deprecated), +按优先级被 `.mcp.json` 遮蔽、无害。**前提:CAD 必须在跑(8080 监听)**,否则工具调用报错。 +`codebuddy mcp list` 这个 CLI 子命令在本版本会去拉起主程序(撞端口 55187),别用它验证。 ## 为什么需要它(非显而易见) diff --git a/ai.memory/reference_environment.md b/ai.memory/reference_environment.md index 7a18f43..d52b6b5 100644 --- a/ai.memory/reference_environment.md +++ b/ai.memory/reference_environment.md @@ -43,6 +43,10 @@ type: reference | `verify_mcp_fault_injection.py` | 工具异常保护(类型错误不再崩 CAD) | | `verify_stdio_bridge.py` | stdio ↔ TCP 桥(协议协商 / 转发 / 通知) | | `verify_annotate.py` | 批注工具运行时验证(绘制/校验/改属性/清理);`--keep` 保留批注 | +| `verify_heatmap.py` | 实体分布热力图(占用网格 + 聚类 + PNG),**不耗 token**,缓存 bbox JSON 供离线调参 | +| `verify_vlm_resolution_offline.py` | **离线**(纯 PIL 合成)标定 VLM 读图下限 `R_text`/`R_line`,`--dry-run` 只出图 | +| `verify_text_resolution.py` | **CAD 实测**标定(临时加字 + 平行线,测完 `delete_entities` 清理);`--keep` 保留 | +| `verify_drawing_area.py` | 绘图区最大化验收:基线 → `set_drawing_area(max)` → `restore`,断言窗口矩形/最大化态/绘图区尺寸全回位(实测 3.68×) | > 历史:根目录 `test.py` / `test_autoagent.py` / `countOfCircle.py` / `agent_loop.py` / > `dwg_review_agent.py` 五个遗留调试脚本已于 2026-10-14 删除(前三个含硬编码 API Key, @@ -62,6 +66,17 @@ type: reference - `c++_dll`:`LoadLibrary`(**同一 DLL 只加载一次**)+ 工厂/销毁函数(**同步**,可返回图像)。 版本校验与「写库需文档锁」两个坑见 [MCP 基座 c++_dll 加载与执行的坑](project_mcp_frame_dll_loader.md)。 +## 工具语义坑:`query_entities` 的两个 filter(2026-10-15 实测) + +- **`type_filter` 必须是「数组」**(精确类型名列表,如 `["TEXT"]`)。 + 传**字符串**会被**静默忽略**——`typeMatches` 对「非数组」一律 `return true`(全通过), + 于是你以为筛了文字,实际把 LINE 等全带回来了(踩过:标定脚本因此把窗口中心算到无文字处)。 +- **`layer_filter` 是「字符串 + 通配」**(`wildcardMatch`),**不是**列表。 + 传列表会被忽略 → 通配为空 → **匹配所有图层**(清理类操作慎用)。 +- 另有 `space`(模型/图纸空间)、`limit`(**硬钳 1..1000**)、`offset`(分页)。 +- 相关工具返回体:`add_text` / `add_line` 等返回 `{"ok":true,"count":1,"created":[{handle,layer,type}]}` + —— 句柄在 **`created`** 键下(不是 `items`)。 + ## 远程仓库 - 现状(2026-10-08 起):`CadProject` 已从「某仓库的子目录」**独立为单独仓库**。 @@ -75,3 +90,12 @@ type: reference 旧密钥 `~/.ssh/id_ed25519`(`deploy@aly-db`)已被服务器移除,会 `Permission denied (publickey)`。 本仓库已设置 `git config core.sshCommand "ssh -i ~/.ssh/id_ed25519_guojianming -o IdentitiesOnly=yes"`。 换机器时需补该配置或加入 `~/.ssh/config`。 + +## 真实测试图纸(2026-10-15) + +- 图纸:`e:\desktop\agent_test.dwg`(用户制作的真实 Plant 3D 图纸:3 个图框 A0/A1/A2, + 86 个实体:`AECDBEWFITTING` 40 / `AECDBEWPIPE` 29 / `AECDBEWEQUIPMENT` 14 / `INSERT` 3)。 +- **启动方式**:`acad.exe /p SHS-ENVI2026 e:\desktop\agent_test.dwg` + —— 必须带 `/p SHS-ENVI2026` profile,否则**自定义实体无法显示**(该 profile 自动加载 + 用户写的显示插件)。 +- 实测:`python -m dwg_review --frames 1` 端到端可跑通并出报告。 diff --git a/ai.memory/tool_request_format.md b/ai.memory/tool_request_format.md new file mode 100644 index 0000000..c6bcf99 --- /dev/null +++ b/ai.memory/tool_request_format.md @@ -0,0 +1,126 @@ +--- +name: 工具需求格式(Tool Request Format) +description: 当 cad_mcp_frame 现有工具无法完成某能力时,agent 应主动输出的结构化「工具需求」模板——字段面向"由未来的 AI 自己实现"设计,含实现线索 +type: reference +--- + +# 工具需求格式(Tool Request Format) + +> 用途:当 `cad_mcp_frame` 的现有工具**无法完成**某项能力时,agent **不要硬凑、不要用不可靠的替代**, +> 而应主动输出一条结构化「工具需求」。 +> 本格式**首先是给 agent 自己读的**——因为最终实现这个工具的人也是 AI(未来的会话), +> 所以字段要**自包含**(不依赖当时的对话上下文)且**可直接据此落地实现**。 + +## 何时提 + +- 需要的数据现有工具给不出(如自定义实体只返回 bbox、读不到几何)。 +- 现有工具能勉强做但**精度/可靠性不够**(如用 bbox 相贴推断连通性会漏判)。 +- 某类操作要**多次调用拼凑**才能完成,明显该有专用工具。 + +## 输出格式(我提需求时用这个模板) + +```yaml +tool_request: + id: <小写短横线 slug,唯一> # 例: get-entity-geometry + title: <一句话能力> + date: + context: <触发场景,一句话> + + problem: | + <我在做什么、卡在哪> + evidence: | # 具体证据:handle/坐标/工具返回,便于复现 + <...> + tried: # 现有工具为什么不够 + - {tool: <名>, result: <为什么不行>} + + proposed: + name: <建议工具名,蛇形> + summary: <做什么> + inputs: # 对齐现有 inputSchema 风格 + - {name: <参数>, type: <类型>, required: , desc: <说明>} + outputs: # 我期望拿到的字段 + - <字段: 含义> + example_call: | + {"name":"<工具名>","arguments":{...}} + example_output: | + {"items":[...]} + + acceptance: # "做好了"的可验证标准 + - <条件> + + implementation_hints: # 给未来的我:怎么落地(可省,但强烈建议填) + backend: c++_dll + files: [<要新增/改动的源文件>] + register: [<要同步注册的配置>] + conventions: + - 返回值用 makeJsonResult() 信封 {"result":{"content":[...]}} + - 新增 .cpp/.h 必须 UTF-8 with BOM + - 仅写库工具需文档锁;读工具不需要 + - 完成后跑对应 verify_* 回归 + + workaround: <当前临时替代及其局限> + priority: + status: open # open | implementing | done +``` + +## 我(未来)实现时怎么用 + +0. **动手实现前,先与用户讨论确认**(接口/命名/范围),得到认可再落地。用户明确要求 + (2026-10-14):"实现这个工具之前,需要和我讨论一下。" +1. 直接按 `implementation_hints` 落地:源码放 `src/cad_mcp_plugins/`,并同步注册 + `src/cad_mcp_frame/cad-mcp-frame.json` 与 `bin/Debug-R230/mcp_config.json`。 +2. 字段信息不足时**先向用户补问**,不要臆测接口。 +3. 完成后把 `status` 改为 `done`,并记一句验证方式(哪个 `verify_*.py` / 实测结论)。 + +## 示例(当前真实需求,可直接实现) + +```yaml +tool_request: + id: get-entity-geometry + title: 读取自定义实体(AECDBEWPIPE/FITTING/EQUIPMENT)的实际连接点/起止点坐标 + date: 2026-10-14 + context: 审查图纸时需判断管线连通性 + + problem: | + 判断每个图框内管线是否连续时,只能拿到实体外包框,无法判断真实几何是否相接。 + evidence: | + get_entity_detail("3A4") 返回 properties 为空;query_entities 只给 bbox。 + 两条共线管段若 bbox 相贴但实际有缺口,会被误判为连通。 + tried: + - {tool: get_entity_detail, result: 对 AECDBEWPIPE 返回空 properties} + - {tool: query_entities, result: 只有 bbox,bbox 相贴≠几何相连,会漏判断口} + + proposed: + name: get_entity_geometry + summary: 返回实体的实际几何(直线段起止点 / 连接点数组 / 中心线走向) + inputs: + - {name: handles, type: array, required: true, desc: 实体句柄列表} + - {name: window, type: object, required: false, desc: 范围过滤} + - {name: space, type: string, required: false, desc: "Model 或布局名,默认 Model"} + outputs: + - "handle: 实体句柄" + - "type: 实体类型" + - "start/end: 起止点 [x,y](直线段)" + - "points: 连接点数组(多段/异形)" + example_call: | + {"name":"get_entity_geometry","arguments":{"handles":["3A4"]}} + example_output: | + {"items":[{"handle":"3A4","type":"AECDBEWPIPE","start":[25937.6,45779.3],"end":[29946.5,45779.3]}]} + + acceptance: + - 对 AECDBEWPIPE/FITTING/EQUIPMENT 均能返回非空几何 + - 用其起止点可精确判定相邻实体是否真正相接(不再依赖 bbox) + + implementation_hints: + backend: c++_dll + files: [src/cad_mcp_plugins/dwg_review_query.cpp] + register: [src/cad_mcp_frame/cad-mcp-frame.json, bin/Debug-R230/mcp_config.json] + conventions: + - 返回值用 makeJsonResult() 信封 + - 源文件 UTF-8 with BOM + - 读工具无需文档锁 + + workaround: 用 query_entities 的 bbox 做相邻推断,精度不足、会漏判断口 + priority: high + status: open +``` diff --git a/conftest.py b/conftest.py index dc9becc..0f38a57 100644 --- a/conftest.py +++ b/conftest.py @@ -15,11 +15,12 @@ if str(_ROOT) not in sys.path: @pytest.fixture(autouse=True) -def _no_render_wait(monkeypatch): - """消除 collect_frame 里每个图框的 RENDER_WAIT 等待(单元测试不需要真实渲染)。""" +def _fast_defaults(monkeypatch): + """单元测试默认:不等待渲染、取证只取整体单图(多图用例自行覆盖 tiles)。""" from dwg_review import config monkeypatch.setattr(config, "RENDER_WAIT", 0) + monkeypatch.setattr(config, "EVIDENCE_DETAIL_TILES", 0) @pytest.fixture diff --git a/dwg_review/__main__.py b/dwg_review/__main__.py index a49ca13..25b86ae 100644 --- a/dwg_review/__main__.py +++ b/dwg_review/__main__.py @@ -11,6 +11,7 @@ from . import findings as findings_mod from .annotator import annotate, clear from .checklist import build_system_prompt, checklist_summary from .context import ReviewContext +from .evidence_model import evidence_images from .mcp_client import McpError, McpToolError, result_json from .phases import evidence as evidence_phase from .phases import planning, report, review @@ -20,7 +21,8 @@ def _dump_evidence(evidences): out_dir = Path(config.REPORT_DIR) out_dir.mkdir(parents=True, exist_ok=True) path = out_dir / "evidence.json" - trimmed = [{k: v for k, v in e.items() if k != "image_b64"} for e in evidences] + drop = ("images", "image_b64", "image_mime") + trimmed = [{k: v for k, v in e.items() if k not in drop} for e in evidences] path.write_text(json.dumps(trimmed, ensure_ascii=False, indent=2), encoding="utf-8") return path @@ -62,20 +64,23 @@ def run_review(ctx, args): print("[2/4] 取证:逐图框采集(串行) ...") evidences = [] - for idx, frame in enumerate(frames, 1): - print( - f" - 图框 {idx}/{len(frames)} kind={frame.get('kind')} " - f"sheet={frame.get('sheet_size')}" - ) - ev = evidence_phase.collect_frame(ctx, idx, frame) - if ev is None: - print(" [跳过] 缺少有效包围盒") - continue - evidences.append(ev) - print( - f" 图元 {(ev.get('entities') or {}).get('total')} 条," - f"截图 {'有' if ev.get('image_b64') else '无'}" - ) + # 截屏前把绘图区最大化(窗口铺满 + 干净屏幕),采集完在 finally 里还原; + # 旧版插件没有该工具时静默降级。 + with evidence_phase.maximized_drawing_area(ctx): + for idx, frame in enumerate(frames, 1): + print( + f" - 图框 {idx}/{len(frames)} kind={frame.get('kind')} " + f"sheet={frame.get('sheet_size')}" + ) + ev = evidence_phase.collect(ctx, idx, frame) + if ev is None: + print(" [跳过] 缺少有效包围盒") + continue + evidences.append(ev) + print( + f" 图元 {(ev.get('entities') or {}).get('total')} 条," + f"截图 {len(evidence_images(ev))} 张" + ) if args.plan_only: path = _dump_evidence(evidences) diff --git a/dwg_review/config.py b/dwg_review/config.py index 6a68d10..9a6c461 100644 --- a/dwg_review/config.py +++ b/dwg_review/config.py @@ -121,6 +121,53 @@ IMAGE_FORMAT = os.environ.get("IMAGE_FORMAT", "png").strip().lower() # JPEG 质量(仅 IMAGE_FORMAT=jpeg 时生效) IMAGE_JPEG_QUALITY = int(os.environ.get("IMAGE_JPEG_QUALITY", "80")) +# 取证阶段每图框的局部截图数(0=仅整体;4=整体+2x2 象限) +# —— 仅在自适应瓦片规划不可用时作为**回退**路径参数 +EVIDENCE_DETAIL_TILES = int(os.environ.get("EVIDENCE_DETAIL_TILES", "4")) +# 每图框图片硬上限(整体 + 局部总张数)。 +# 默认 12 才能装下规划器给出的瓦片(实测测试图 A0 需 12 张);调小会截断规划结果。 +EVIDENCE_MAX_IMAGES = int(os.environ.get("EVIDENCE_MAX_IMAGES", "12")) + +# --------------------------------------------------------------------------- +# 自适应瓦片规划(按实体分布决定截几张、截哪里) +# --------------------------------------------------------------------------- +# 是否启用瓦片规划;False = 退回固定 EVIDENCE_DETAIL_TILES(行为与旧版一致) +TILING_ENABLED = _read_bool("TILING_ENABLED", True) +# 纸面字高(mm)。国标标注字高 2~3mm,取保守下限 +TEXT_HEIGHT_MM = float(os.environ.get("TEXT_HEIGHT_MM", "2.0")) +# VLM 可读的**最小像素字高**。实测与**字体强相关**: +# - 离线合成(Consolas 抗锯齿填充字体)12px(白底 10px); +# - 真实 CAD 单线体 .shx(描边字体)6px 即可读对(3/3,两轮)。 +# 取 8 折中:兼顾描边与常见 TrueType。标定脚本见 verify_vlm_resolution_offline.py / +# verify_text_resolution.py(后者须用 ACI 7 白字——红字 12px 因对比度不足完全读不出)。 +TEXT_READABLE_PX = int(os.environ.get("TEXT_READABLE_PX", "8")) +# VLM 可分辨的**最小线间距像素**(1 条 vs 2 条二选一)。 +# 实测 2/3/4px 失败、5/8/12px 成功;取 6 留一点余量。 +LINE_READABLE_PX = int(os.environ.get("LINE_READABLE_PX", "6")) +# 单图框瓦片数硬上界(成本兜底) +TILE_MAX_COUNT = int(os.environ.get("TILE_MAX_COUNT", "36")) +# 相邻瓦片重叠比例(防边界切字) +TILE_OVERLAP = float(os.environ.get("TILE_OVERLAP", "0.0")) +# 热力图网格划分(长边切几份) +HEATMAP_CELL_DIV = int(os.environ.get("HEATMAP_CELL_DIV", "32")) +# 热力图聚类前的膨胀格数(合并相近内容;0=不膨胀) +HEATMAP_CLUSTER_DILATE = int(os.environ.get("HEATMAP_CLUSTER_DILATE", "0")) +# 丢弃 bbox 面积超过图框该比例的实体(去图框边框/图签块) +HEATMAP_MAX_AREA_FRAC = float(os.environ.get("HEATMAP_MAX_AREA_FRAC", "0.5")) + +# --------------------------------------------------------------------------- +# 绘图区最大化(截屏前把 CAD 窗口铺满屏幕 + 进入干净屏幕) +# --------------------------------------------------------------------------- +# 是否在取证前最大化绘图区。实测绘图区面积 2110x462 → 3310x1084(3.68x), +# 同样的视觉 token 预算下细节更清楚。工具不存在(旧版插件)时**静默降级**。 +DRAWING_AREA_MAXIMIZE = _read_bool("DRAWING_AREA_MAXIMIZE", True) +# 进入干净屏幕后等待 CAD 渲染 / 重排窗口的秒数 +DRAWING_AREA_ENTER_WAIT = float(os.environ.get("DRAWING_AREA_ENTER_WAIT", "1.5")) +# 还原后等待的秒数。插件内部是「700ms 延迟 + SetWindowPlacement」(CLEANSCREENOFF 是 +# 异步命令,会把窗口几何改回它记住的布局,必须等它跑完再压回),实测窗口在 0.5~1.0s +# 之间收敛,故取 1.5s 留余量——太小会在界面还没回位时就开始下一步。 +DRAWING_AREA_RESTORE_WAIT = float(os.environ.get("DRAWING_AREA_RESTORE_WAIT", "1.5")) + def has_api_key() -> bool: return bool(API_KEY.strip()) diff --git a/dwg_review/evidence_model.py b/dwg_review/evidence_model.py new file mode 100644 index 0000000..f0cad13 --- /dev/null +++ b/dwg_review/evidence_model.py @@ -0,0 +1,88 @@ +"""统一证据模型:多来源(CAD / PDF / 图片)证据的字段常量与访问器。 + +设计要点: + - evidence 是跨阶段 + 落盘的「线格式」数据,统一用 dict 承载(不用 dataclass)。 + - 图片统一为 `images: [{"b64","mime","label"}]`;访问器兼容旧的 `image_b64/image_mime` + 单图字段(历史 evidence.json 与旧测试构造仍可读)。 + - 本模块不含任何 CAD / 工具逻辑,可被 phases、findings、__main__、tests 共同引用。 +""" + +SOURCE_CAD = "cad" +SOURCE_PDF = "pdf" +SOURCE_IMAGE = "image" + +# 结构化证据键(CAD 侧各查询工具产出的信封);下游 handle 收集 / 派生检查统一引用, +# 避免在多处硬编码同一组键名。 +STRUCTURED_SOURCES = ("entities", "texts", "dimensions", "blocks", "polylines") + + +def make_image(b64, mime="image/png", label=None): + """构造 images 列表元素。""" + return {"b64": b64, "mime": mime or "image/png", "label": label} + + +def source_type_of(container): + """读取来源类型,缺省视为 CAD。 + + container 可以是审查单元(unit)/ 图框 / evidence 等任何 dict。 + """ + if isinstance(container, dict): + value = container.get("source_type") + if isinstance(value, str) and value: + return value + return SOURCE_CAD + + +def evidence_images(evidence): + """规范化证据里的图片列表(单点归一)。 + + 优先读 `images`;否则回退旧的 `image_b64` / `image_mime` 单图字段。 + 统一返回 `[{"b64","mime","label"}]`,过滤掉空 b64。 + """ + if not isinstance(evidence, dict): + return [] + images = [] + raw = evidence.get("images") + if isinstance(raw, list): + for item in raw: + if not isinstance(item, dict): + continue + b64 = item.get("b64") + if not b64: + continue + images.append( + { + "b64": b64, + "mime": item.get("mime") or "image/png", + "label": item.get("label"), + } + ) + if images: + return images + # 回退:旧的单图字段 + b64 = evidence.get("image_b64") + if b64: + return [ + { + "b64": b64, + "mime": evidence.get("image_mime") or "image/png", + "label": None, + } + ] + return [] + + +def primary_image(evidence): + """返回首图(label == "overall" 优先),无图返回 None。""" + images = evidence_images(evidence) + if not images: + return None + for img in images: + if img.get("label") == "overall": + return img + return images[0] + + +def image_labels(evidence): + """返回图片标签列表(用于日志 / payload 说明)。""" + return [img.get("label") for img in evidence_images(evidence)] diff --git a/dwg_review/heatmap.py b/dwg_review/heatmap.py new file mode 100644 index 0000000..40d9b6f --- /dev/null +++ b/dwg_review/heatmap.py @@ -0,0 +1,337 @@ +"""实体分布热力图:把实体 AABB 聚合为「占用网格 + 簇」,供自适应瓦片规划使用。 + +纯函数模块(仅依赖 `math`);可视化 `render_overlay` 内部按需 import Pillow, +使本模块在无 Pillow 的环境下仍可被单测与主流程引用。 + +坐标约定(与 CAD 一致): + - 世界坐标 x 向右、y **向上**;bbox = (min_x, min_y, max_x, max_y)。 + - 网格 row 0 在**最上方**、col 0 在**最左**(与截图 / 图片一致),便于直接对照画面。 +""" + +import math + +# 退化 bbox(线/点)的膨胀比例(相对格尺寸),保证零宽/零高的实体也能被计入 +_EPS_FRAC = 1e-3 + + +def norm_box(b): + """把任意 bbox 归一为 (minx,miny,maxx,maxy) 的 float 元组;非法返回 None。""" + if not isinstance(b, (list, tuple)) or len(b) != 4: + return None + try: + x1, y1, x2, y2 = (float(v) for v in b) + except (TypeError, ValueError): + return None + if not all(math.isfinite(v) for v in (x1, y1, x2, y2)): + return None + if x1 > x2: + x1, x2 = x2, x1 + if y1 > y2: + y1, y2 = y2, y1 + return (x1, y1, x2, y2) + + +def entity_boxes(envelopes): + """从若干查询信封收集合法实体 bbox。 + + envelopes 可以是单个信封 dict,也可以是信封的可迭代集合;每个信封形如 + `{"items": [{"bbox": [..]}, ...], "total": N}`(其它字段忽略)。 + 返回 [(minx,miny,maxx,maxy), ...];缺字段 / 非法的条目直接跳过。 + """ + if isinstance(envelopes, dict): + envelopes = [envelopes] + out = [] + for env in envelopes or []: + if not isinstance(env, dict): + continue + items = env.get("items") + if not isinstance(items, list): + continue + for it in items: + if not isinstance(it, dict): + continue + box = norm_box(it.get("bbox")) + if box is not None: + out.append(box) + return out + + +def box_area(b): + """bbox 面积。""" + return max(0.0, b[2] - b[0]) * max(0.0, b[3] - b[1]) + + +def union_box(boxes): + """若干 bbox 的并集包围盒;空集合返回 None。""" + boxes = [b for b in boxes if b] + if not boxes: + return None + return ( + min(b[0] for b in boxes), + min(b[1] for b in boxes), + max(b[2] for b in boxes), + max(b[3] for b in boxes), + ) + + +def drop_oversized(boxes, window, max_frac=0.5): + """丢弃 bbox 面积超过图框面积 `max_frac` 的实体。 + + 这类实体多是**图框边框 / 图签块**等"结构"(实测每个图框恰有 1 个 bbox 等于整幅图框), + 其 bbox 会铺满整张热力图、掩盖真正的细节分布;它们无细节可读,整体图已能覆盖。 + """ + area = (float(window["max_x"]) - float(window["min_x"])) * ( + float(window["max_y"]) - float(window["min_y"]) + ) + if area <= 0: + return list(boxes) + limit = area * float(max_frac) + return [b for b in boxes if box_area(b) <= limit] + + +def div_to_cell(window, div): + """按「长边切 div 份」求格子世界尺寸。""" + w = float(window["max_x"]) - float(window["min_x"]) + h = float(window["max_y"]) - float(window["min_y"]) + div = max(1, int(div)) + return max(w, h) / div + + +def build_grid(window, boxes, cell): + """把 window 按 cell 尺寸切成网格,统计每格命中的实体数与覆盖比例。 + + 返回 dict: + {"window", "cols", "rows", "cell_w", "cell_h", + "counts": [[int]], "coverage": [[float]]} + - counts[r][c] :与格 (r,c) 相交的实体数(同一实体跨多格则各计一次); + - coverage[r][c]:该格被实体 bbox 覆盖的面积比例(0~1,多实体叠加后夹到 1)。 + """ + min_x, min_y = float(window["min_x"]), float(window["min_y"]) + max_x, max_y = float(window["max_x"]), float(window["max_y"]) + cell = float(cell) + if cell <= 0 or max_x <= min_x or max_y <= min_y: + return { + "window": window, "cols": 0, "rows": 0, "cell_w": 0.0, "cell_h": 0.0, + "counts": [], "coverage": [], + } + + cols = max(1, int(math.ceil((max_x - min_x) / cell))) + rows = max(1, int(math.ceil((max_y - min_y) / cell))) + cw = (max_x - min_x) / cols + ch = (max_y - min_y) / rows + counts = [[0] * cols for _ in range(rows)] + coverage = [[0.0] * cols for _ in range(rows)] + + for b in boxes: + bx1, by1, bx2, by2 = b + # 退化 bbox(水平/垂直线、点)宽或高为 0,按格尺寸的千分之一膨胀,保证能被计入 + if bx2 - bx1 <= 0: + bx1 -= cw * _EPS_FRAC * 0.5 + bx2 += cw * _EPS_FRAC * 0.5 + if by2 - by1 <= 0: + by1 -= ch * _EPS_FRAC * 0.5 + by2 += ch * _EPS_FRAC * 0.5 + # 与 window 求交,完全在外的直接跳过 + bx1, by1 = max(bx1, min_x), max(by1, min_y) + bx2, by2 = min(bx2, max_x), min(by2, max_y) + if bx1 > bx2 or by1 > by2: + continue + c0 = min(max(int((bx1 - min_x) / cw), 0), cols - 1) + c1 = min(max(int((bx2 - min_x) / cw), 0), cols - 1) + # y 向上 → row 0 在最上方 + r0 = min(max(int((max_y - by2) / ch), 0), rows - 1) + r1 = min(max(int((max_y - by1) / ch), 0), rows - 1) + for r in range(r0, r1 + 1): + cy_top = max_y - r * ch + cy_bot = cy_top - ch + for c in range(c0, c1 + 1): + cx1 = min_x + c * cw + cx2 = cx1 + cw + ix = max(0.0, min(bx2, cx2) - max(bx1, cx1)) + iy = max(0.0, min(by2, cy_top) - max(by1, cy_bot)) + # 用真实交面积判定命中,避免恰好落在格边界上的实体被计入相邻空格 + if ix <= 0 or iy <= 0: + continue + counts[r][c] += 1 + coverage[r][c] = min(1.0, coverage[r][c] + (ix * iy) / (cw * ch)) + + return { + "window": window, "cols": cols, "rows": rows, + "cell_w": cw, "cell_h": ch, "counts": counts, "coverage": coverage, + } + + +def occupied_cells(grid, min_count=1): + """占用格坐标列表 [(row, col), ...](counts >= min_count),行优先。""" + counts = grid.get("counts") or [] + out = [] + for r, row in enumerate(counts): + for c, n in enumerate(row): + if n >= min_count: + out.append((r, c)) + return out + + +def _dilate(mask, radius): + """把布尔占用掩码向外膨胀 radius 格(半径 0 原样返回)。""" + if radius <= 0: + return mask + rows, cols = len(mask), len(mask[0]) if mask else 0 + out = [[False] * cols for _ in range(rows)] + for r in range(rows): + for c in range(cols): + if not mask[r][c]: + continue + for dr in range(-radius, radius + 1): + for dc in range(-radius, radius + 1): + rr, cc = r + dr, c + dc + if 0 <= rr < rows and 0 <= cc < cols: + out[rr][cc] = True + return out + + +def cluster_cells(grid, min_count=1, dilate=0): + """把占用格按 4-连通聚成若干簇(可选先膨胀 dilate 格,合并相近内容)。 + + 返回按「实体计数降序」排列的簇列表,每项: + {"bbox": (minx,miny,maxx,maxy), "cells": [(r,c),...], "count": int} + bbox 为该簇**实际占用格**的并集(不含膨胀出来的空格),故不会虚增范围。 + """ + rows, cols = grid.get("rows", 0), grid.get("cols", 0) + counts = grid.get("counts") or [] + if not rows or not cols or not counts: + return [] + + raw = [[counts[r][c] >= min_count for c in range(cols)] for r in range(rows)] + mask = _dilate(raw, int(dilate)) + + seen = [[False] * cols for _ in range(rows)] + clusters = [] + for r in range(rows): + for c in range(cols): + if not mask[r][c] or seen[r][c]: + continue + stack = [(r, c)] + seen[r][c] = True + cells = [] + while stack: + cr, cc = stack.pop() + if raw[cr][cc]: + cells.append((cr, cc)) + for dr, dc in ((1, 0), (-1, 0), (0, 1), (0, -1)): + nr, nc = cr + dr, cc + dc + if 0 <= nr < rows and 0 <= nc < cols and mask[nr][nc] and not seen[nr][nc]: + seen[nr][nc] = True + stack.append((nr, nc)) + if not cells: + continue # 膨胀出来的孤立空格,无实体,丢弃 + boxes = [_cell_box(grid, cr, cc) for cr, cc in cells] + clusters.append({ + "bbox": union_box(boxes), + "cells": sorted(cells), + "count": sum(counts[cr][cc] for cr, cc in cells), + }) + + clusters.sort(key=lambda cl: (-cl["count"], -cl["bbox"][3], cl["bbox"][0])) + return clusters + + +def _cell_box(grid, r, c): + """格 (r,c) 的世界 bbox(row 0 在上)。""" + win = grid["window"] + min_x, max_y = float(win["min_x"]), float(win["max_y"]) + cw, ch = grid["cell_w"], grid["cell_h"] + x1 = min_x + c * cw + y_top = max_y - r * ch + return (x1, y_top - ch, x1 + cw, y_top) + + +def grid_to_windows(clusters, margin=0.0): + """把簇 bbox 转成 zoom 窗口列表(保持输入顺序,即实体计数降序)。""" + out = [] + for cl in clusters: + x1, y1, x2, y2 = cl["bbox"] + w, h = x2 - x1, y2 - y1 + mx, my = w * margin, h * margin + out.append({ + "min_x": x1 - mx, "min_y": y1 - my, + "max_x": x2 + mx, "max_y": y2 + my, + }) + return out + + +def grid_stats(grid, clusters=None): + """统计摘要(供日志 / 目视对照):占用率、实体数分布、簇数与最大簇。""" + rows, cols = grid.get("rows", 0), grid.get("cols", 0) + total = rows * cols + occ = occupied_cells(grid) + counts = grid.get("counts") or [] + hits = [n for row in counts for n in row if n > 0] + stats = { + "rows": rows, "cols": cols, + "occupied": len(occ), + "occupied_ratio": (len(occ) / total) if total else 0.0, + "entity_cell_hits": len(hits), + "max_count": max(hits) if hits else 0, + } + if clusters is not None: + stats["clusters"] = len(clusters) + stats["max_cluster_cells"] = max((len(cl["cells"]) for cl in clusters), default=0) + stats["max_cluster_count"] = max((cl["count"] for cl in clusters), default=0) + return stats + + +def render_overlay(path, window, boxes, grid, clusters=None, *, size=1200, bg=(18, 18, 18)): + """把热力图渲染成 PNG 供人工目视:窗口边框 + 实体 bbox 轮廓 + 热力着色 + 簇框。 + + - 长边缩放到 size 像素,保持世界坐标长宽比; + - 热力色:占用越密越亮(绿→黄→红); + - 簇框用青色粗线标出。 + 需要 Pillow;缺失时抛 RuntimeError(调用方自行决定是否忽略)。 + """ + try: + from PIL import Image, ImageDraw + except ImportError as exc: # pragma: no cover - 依赖缺失 + raise RuntimeError("渲染热力图需要 Pillow") from exc + + min_x, min_y = float(window["min_x"]), float(window["min_y"]) + max_x, max_y = float(window["max_x"]), float(window["max_y"]) + w, h = max_x - min_x, max_y - min_y + if w <= 0 or h <= 0: + raise ValueError("window 尺寸非法") + + scale = size / max(w, h) + iw, ih = max(1, int(round(w * scale))), max(1, int(round(h * scale))) + img = Image.new("RGB", (iw, ih), bg) + draw = ImageDraw.Draw(img) + + def to_px(x, y): + return (int(round((x - min_x) * scale)), int(round((max_y - y) * scale))) + + # 热力格子 + counts = grid.get("counts") or [] + max_count = max((n for row in counts for n in row), default=0) or 1 + for r, row in enumerate(counts): + for c, n in enumerate(row): + if n <= 0: + continue + x1, y1, x2, y2 = _cell_box(grid, r, c) + p1, p2 = to_px(x1, y2), to_px(x2, y1) + t = min(1.0, n / max_count) + color = (int(60 + 195 * t), int(200 - 140 * t), int(60 - 40 * t)) + draw.rectangle([p1, p2], fill=color) + + # 实体 bbox 轮廓(细线) + for b in boxes: + draw.rectangle([to_px(b[0], b[3]), to_px(b[2], b[1])], outline=(90, 90, 90)) + + # 簇框(青色粗线) + for cl in clusters or []: + b = cl["bbox"] + draw.rectangle([to_px(b[0], b[3]), to_px(b[2], b[1])], outline=(0, 220, 220), width=2) + + # 窗口边框(白色) + draw.rectangle([to_px(min_x, max_y), to_px(max_x, min_y)], outline=(255, 255, 255), width=1) + + img.save(str(path)) + return path diff --git a/dwg_review/llm_client.py b/dwg_review/llm_client.py index 7505aa0..a41fa1e 100644 --- a/dwg_review/llm_client.py +++ b/dwg_review/llm_client.py @@ -67,6 +67,21 @@ def downsample_image_b64(b64, mime="image/png", max_edge=None, fmt=None, quality return b64, mime +def image_size_b64(b64, mime="image/png"): + """解码图片并返回 `(宽, 高)`;缺 Pillow / 解码失败返回 None。 + + 用于瓦片规划前测量**绘图区实际像素**(决定单张截图能覆盖多大世界范围)。 + """ + if not HAS_PIL or not b64: + return None + try: + img = Image.open(io.BytesIO(base64.b64decode(b64))) + w, h = img.size + return (int(w), int(h)) + except Exception: + return None + + class LlmClient: """统一的 LLM 调用入口。""" diff --git a/dwg_review/phases/evidence.py b/dwg_review/phases/evidence.py index cf3f6d3..735381a 100644 --- a/dwg_review/phases/evidence.py +++ b/dwg_review/phases/evidence.py @@ -1,14 +1,60 @@ -"""阶段 2:逐图框取证(确定性、串行)。 +"""阶段 2:逐审查单元取证(多源证据层 + 视觉多图)。 -对每个图框:缩放到该框 → 取结构化数据(实体/文字/标注/块/多段线)→ 截取视口。 +对每个审查单元:取结构化数据(实体/文字/标注/块/多段线)+ 视觉截图(整体 + 可选局部)。 结构化数据是权威依据,截图仅作视觉上下文。 + +多源设计:`collect()` 按审查单元来源分发;当前仅实现 CAD 源,PDF / 图片源预留 seam +(未来在对应分支产出**同形** evidence,下游无需改动)。 """ +import contextlib +import math import time -from .. import config +from .. import config, evidence_model, heatmap, tiling +from ..llm_client import image_size_b64 from ..mcp_client import result_images, result_json +_LABEL_OVERALL = "overall" + +# 绘图区最大化用的工具名(旧版插件没有此工具 → 静默降级) +_TOOL_DRAWING_AREA = "set_drawing_area" + + +@contextlib.contextmanager +def maximized_drawing_area(ctx): + """截屏前最大化绘图区(窗口铺满屏幕 + 干净屏幕),退出时还原。 + + 实测绘图区像素 2110x462 → 3310x1084(3.68x);在同样的长边下采样预算下, + 有效像素更多、细节更清楚,自适应瓦片张数也更少。 + + - 受 `config.DRAWING_AREA_MAXIMIZE` 控制; + - 工具不存在(旧版插件)/ 调用失败 → **静默降级**:照常取证,只是绘图区小一些; + - 退出时一定尝试还原;**还原失败不掩盖业务异常**(不 raise)。 + """ + if not config.DRAWING_AREA_MAXIMIZE: + yield + return + + entered = False + try: + if ctx.tools.has(_TOOL_DRAWING_AREA): + ctx.tools.call(_TOOL_DRAWING_AREA, {"mode": "max"}) + entered = True + time.sleep(config.DRAWING_AREA_ENTER_WAIT) + except Exception: # noqa: BLE001 - 界面优化失败不应中断审查 + entered = False + + try: + yield + finally: + if entered: + try: + ctx.tools.call(_TOOL_DRAWING_AREA, {"mode": "restore"}) + time.sleep(config.DRAWING_AREA_RESTORE_WAIT) + except Exception: # noqa: BLE001 + pass + def bbox_to_window(bbox): """[minx,miny,maxx,maxy] -> window 对象。""" @@ -20,8 +66,186 @@ def bbox_to_window(bbox): } +def sub_windows(window, tiles): + """把 window 等分为 tiles 个子窗口(tiles=4 → 2x2 象限)。 + + tiles<=1 返回 []。返回 [{"min_x",..,"max_x",..}, ...],行优先(左上→右上→左下→右下)。 + """ + if not tiles or tiles <= 1: + return [] + cols = int(math.ceil(math.sqrt(tiles))) + rows = int(math.ceil(tiles / cols)) + min_x, min_y = window["min_x"], window["min_y"] + max_x, max_y = window["max_x"], window["max_y"] + w = (max_x - min_x) / cols + h = (max_y - min_y) / rows + out = [] + for r in range(rows): + for c in range(cols): + if len(out) >= tiles: + break + out.append( + { + "min_x": min_x + c * w, + "min_y": max_y - (r + 1) * h, + "max_x": min_x + (c + 1) * w, + "max_y": max_y - r * h, + } + ) + return out + + +def _zoom_and_shot(ctx, window, margin=0.02): + """缩放到窗口、等待渲染、截一张图,返回 (b64, mime) 或 None。""" + ctx.tools.call("zoom_to_window", dict(window, margin=margin)) + time.sleep(config.RENDER_WAIT) + shot = ctx.tools.call("get_viewport_screenshot", {}) + images = result_images(shot) + if not images: + return None + return images[0] + + +def _is_truncated(*envelopes): + """任一信封 `total > len(items)` 即视为**被截断**(数据不全)。""" + for env in envelopes: + if not isinstance(env, dict): + continue + items = env.get("items") + if not isinstance(items, list): + continue + try: + total = int(env.get("total") or len(items)) + except (TypeError, ValueError): + total = len(items) + if total > len(items): + return True + return False + + +def _text_boxes(entities, texts, dimensions): + """收集「文字类」实体 bbox。 + + 标准文字 / 标注信封(`get_text_content` / `get_dimensions`)全收; + 再加**类名含 text/label/dim 等关键词**的自定义实体(`tiling.is_text_type`)。 + """ + boxes = list(heatmap.entity_boxes(texts)) + boxes.extend(heatmap.entity_boxes(dimensions)) + items = entities.get("items") if isinstance(entities, dict) else None + for it in items or []: + if isinstance(it, dict) and tiling.is_text_type(it.get("type")): + b = heatmap.norm_box(it.get("bbox")) + if b is not None: + boxes.append(b) + return boxes + + +def _content_predicate(boxes): + """构造「该窗口内是否有实体」谓词;无实体返回 None。 + + 用于剔除空白瓦片:簇 / 象限的 bbox 只是**包围盒**,L 形或斜向分布会含大片空白。 + 这里直接按**实体 bbox** 判断——比先量化成占用格更精确(格子的跨界膨胀会把 + 相邻空白瓦片也判成有内容)。接触即算有内容(保守,宁可多拍不漏)。 + """ + boxes = [b for b in (boxes or []) if b] + if not boxes: + return None + + def _has_content(win): + box = (win["min_x"], win["min_y"], win["max_x"], win["max_y"]) + return any(tiling.boxes_intersect(box, b) for b in boxes) + + return _has_content + + +def _apply_keep(regions, keep): + """按 keep 过滤窗口;过滤后为空则**退回原集合**(宁可多拍也不漏)。""" + if keep is None or not regions: + return regions + kept = [w for w in regions if keep(w)] + return kept or regions + + +def _plan_tiles(window, frame, boxes, text_boxes, truncated, view_px): + """尝试按实体分布规划瓦片窗口;任何条件不满足都返回 [](调用方退回固定策略)。 + + 降级条件:规划被关闭 / 实体数据被截断 / 缺图框 / 无实体 / 拿不到绘图区像素 / + 比例未知(`frame_scale` 返 0)。 + """ + if not config.TILING_ENABLED or truncated or not frame or not boxes or not view_px: + return [] + scale = tiling.frame_scale(frame) + s_text = tiling.world_per_px(scale, config.TEXT_HEIGHT_MM, config.TEXT_READABLE_PX) + # 线条区只需看清走向:同以「最小关注特征尺寸」(纸面 TEXT_HEIGHT_MM)为基准, + # 但像素门槛更低(R_line < R_text)→ 覆盖范围更大、瓦片更少。 + s_line = tiling.world_per_px(scale, config.TEXT_HEIGHT_MM, config.LINE_READABLE_PX) + if s_text <= 0 or s_line <= 0: + return [] + eff = tiling.effective_px(view_px, config.IMAGE_MAX_EDGE) + if eff[0] <= 0 or eff[1] <= 0: + return [] + + cell = heatmap.div_to_cell(window, config.HEATMAP_CELL_DIV) + grid = heatmap.build_grid(window, boxes, cell) + clusters = heatmap.cluster_cells(grid, dilate=config.HEATMAP_CLUSTER_DILATE) + if not clusters: + return [] + flags = tiling.cluster_text_flags(clusters, text_boxes) + text_idx = {i for i, is_text in enumerate(flags) if is_text} + + return tiling.plan_regions( + frame, clusters, + text_clusters=text_idx, s_text=s_text, s_line=s_line, view_px=eff, + max_tiles=config.TILE_MAX_COUNT, overlap=config.TILE_OVERLAP, + keep=_content_predicate(boxes), + ) + + +def capture_images(ctx, window, *, tiles=0, max_images=None, frame=None, + boxes=None, text_boxes=None, truncated=False): + """采集视觉证据:整体一张 + 局部若干张(自适应瓦片优先,否则等分象限回退)。 + + - 整体 label="overall",局部 label="tile-1"..;行优先。 + - 局部窗口优先由 `_plan_tiles` 按**实体分布**给出(空白区不出图); + 不可用时退回 `sub_windows(window, tiles)`,**同样按内容过滤**空白象限 + (过滤后为空则退回未过滤集合,保证不漏拍)。 + - 结束时**复位到整体视口**,保证后续流程从整幅视图起手。 + - 受 max_images(默认 config.EVIDENCE_MAX_IMAGES)截断。 + """ + if max_images is None: + max_images = config.EVIDENCE_MAX_IMAGES + images = [] + + def _add(shot, label): + if shot and len(images) < max_images: + images.append(evidence_model.make_image(shot[0], shot[1], label)) + + # 整体图:既是证据,也用于测量绘图区**实际像素**(决定单张截图的世界覆盖范围) + overall = _zoom_and_shot(ctx, window) + view_px = image_size_b64(overall[0]) if overall else None + _add(overall, _LABEL_OVERALL) + + # 内容过滤:空白区不出图。按**实体 bbox** 判定,规划与回退两条路径共用同一谓词; + # 实体数据被截断时不可信,放弃过滤(宁可多拍也不漏)。 + keep = None if truncated else _content_predicate(boxes) + + # 局部:优先自适应瓦片,失败退回等分象限(回退路径同样按内容过滤) + regions = _plan_tiles(window, frame, boxes, text_boxes, truncated, view_px) + if not regions: + regions = _apply_keep(sub_windows(window, tiles), keep) + + for i, sub in enumerate(regions, 1): + if len(images) >= max_images: + break + _add(_zoom_and_shot(ctx, sub), f"tile-{i}") + + # 复位整体视口(不截图) + ctx.tools.call("zoom_to_window", dict(window, margin=0.02)) + return images + + def collect_frame(ctx, index, frame): - """采集单个图框的证据;bbox 无效时返回 None。""" + """采集单个图框(CAD 源)的证据;bbox 无效时返回 None。""" bbox = frame.get("bbox") if not bbox or len(bbox) != 4: return None @@ -29,11 +253,7 @@ def collect_frame(ctx, index, frame): win = bbox_to_window(bbox) limit = ctx.frame_limit - # a. 缩放到图框(给 CAD 留渲染时间) - ctx.tools.call("zoom_to_window", dict(win, margin=0.02)) - time.sleep(config.RENDER_WAIT) - - # b. 结构化数据(权威) + # a. 结构化数据(权威;按 window 查询,不依赖缩放) entities = result_json(ctx.tools.call("query_entities", {"window": win, "limit": limit}), {}) texts = result_json(ctx.tools.call("get_text_content", {"window": win, "limit": limit}), {}) dimensions = result_json(ctx.tools.call("get_dimensions", {"window": win, "limit": limit}), {}) @@ -44,12 +264,22 @@ def collect_frame(ctx, index, frame): ctx.tools.call("get_polylines_info", {"window": win, "limit": limit}), {} ) - # c. 视口截图(视觉上下文) - shot = ctx.tools.call("get_viewport_screenshot", {}) - images = result_images(shot) - image_b64, image_mime = images[0] if images else (None, None) + # b. 视觉:整体 + 局部(优先按实体分布自适应瓦片;数据截断则退回固定策略) + truncated = _is_truncated(entities, texts, dimensions, blocks, polylines) + boxes = heatmap.drop_oversized( + heatmap.entity_boxes(entities), win, config.HEATMAP_MAX_AREA_FRAC + ) + images = capture_images( + ctx, win, + tiles=config.EVIDENCE_DETAIL_TILES, + frame=frame, + boxes=boxes, + text_boxes=_text_boxes(entities, texts, dimensions), + truncated=truncated, + ) return { + "source_type": evidence_model.source_type_of(frame), "index": index, "frame": frame, "window": win, @@ -58,6 +288,18 @@ def collect_frame(ctx, index, frame): "dimensions": dimensions, "blocks": blocks, "polylines": polylines, - "image_b64": image_b64, - "image_mime": image_mime, + "images": images, } + + +def collect(ctx, index, unit): + """按审查单元来源分发取证。 + + 当前仅实现 CAD 源;PDF / 图片源预留(未来在对应分支接入离线解析 / 直接读图, + 产出与 CAD **同形**的 evidence:window=None、结构化键给空信封、images 带 label)。 + """ + source = evidence_model.source_type_of(unit) + if source == evidence_model.SOURCE_CAD: + return collect_frame(ctx, index, unit) + # TODO(pdf/image): 接入离线解析(DWG/PDF)与直接读图。 + return None diff --git a/dwg_review/phases/review.py b/dwg_review/phases/review.py index 2712574..d227ea2 100644 --- a/dwg_review/phases/review.py +++ b/dwg_review/phases/review.py @@ -11,6 +11,7 @@ import json +from .. import evidence_model from ..cad_tools import READ_ONLY_TOOLS, is_write_tool from ..findings import parse_conclusion from ..llm_client import LlmClient, downsample_image_b64 @@ -24,6 +25,7 @@ def evidence_payload(evidence): """把证据整理成给 LLM 的 JSON(剔除 base64 图片,避免撑爆上下文)。""" frame = evidence.get("frame") or {} return { + "source_type": evidence.get("source_type"), "frame_index": evidence.get("index"), "frame": { k: frame.get(k) @@ -72,9 +74,11 @@ def review_frame(ctx, evidence, system_prompt, log=print): + json.dumps(payload, ensure_ascii=False, indent=2), } ] - image_b64 = evidence.get("image_b64") - if image_b64: - nb, nm = downsample_image_b64(image_b64, evidence.get("image_mime") or "image/png") + for img in evidence_model.evidence_images(evidence): + nb, nm = downsample_image_b64(img["b64"], img.get("mime") or "image/png") + label = img.get("label") + if label: + user_content.append({"type": "text", "text": f"(截图:{label})"}) user_content.append(LlmClient.image_content(nb, nm)) messages = [ diff --git a/dwg_review/tiling.py b/dwg_review/tiling.py new file mode 100644 index 0000000..07e2c16 --- /dev/null +++ b/dwg_review/tiling.py @@ -0,0 +1,288 @@ +"""自适应瓦片规划器:按实体分布把图框切成若干 zoom 窗口,使 VLM 能看清细节。 + +纯函数模块(仅依赖 `math`),不依赖 Pillow / CAD。 + +核心思路(用户方案): + 1. 由图框纸张 + 比例换算「CAD 图形单位 / 像素」s; + 2. 由 VLM 可读下限(文字 `R_text` px、线条 `R_line` px)定每张截图的**世界覆盖范围**; + 3. 复用热力图(`heatmap.py`)的**占用网格 + 聚类**得到簇包围盒;簇内是否含**文字类实体** + 决定用细(`s_text`)/ 粗(`s_line`)尺度; + 4. 簇 ≤ 单屏 → 1 张;超出 → 行优先细分;**空白区不出图**(整体图已覆盖); + 受 `max_tiles` 截断,**文字簇优先保留**。 + +坐标约定:与 `heatmap.py` / CAD 一致——x 向右、y **向上**;bbox=(min_x,min_y,max_x,max_y); +瓦片行优先且 row 0 在**最上方**(与 `phases/evidence.py::sub_windows` 一致)。 +""" + +import math + +# 纸张尺寸(mm,长边在前):GB/T 14689 基本幅面 +SHEET_SIZES_MM = { + "A0": (1189.0, 841.0), + "A1": (841.0, 594.0), + "A2": (594.0, 420.0), + "A3": (420.0, 297.0), + "A4": (297.0, 210.0), +} + +# 标准实体名:本身就是文字/标注类 +_STD_TEXT_TYPES = frozenset({"TEXT", "MTEXT", "ATTRIB", "ATTDEF", "DIMENSION"}) + +# 自定义实体类名关键词(用户经验:类名常含 text/label/dim 等) +_TEXT_KEYWORDS = ("TEXT", "LABEL", "DIM", "NOTE", "TAG", "ANNO", "TITLE") + +# 瓦片重叠上限(防边界切字;过大会显著增加张数) +_MAX_OVERLAP = 0.5 + + +def norm_box(b): + """归一 bbox 为 (minx,miny,maxx,maxy);非法返回 None。""" + if not isinstance(b, (list, tuple)) or len(b) != 4: + return None + try: + x1, y1, x2, y2 = (float(v) for v in b) + except (TypeError, ValueError): + return None + if not all(math.isfinite(v) for v in (x1, y1, x2, y2)): + return None + if x1 > x2: + x1, x2 = x2, x1 + if y1 > y2: + y1, y2 = y2, y1 + return (x1, y1, x2, y2) + + +def frame_bbox(frame): + """图框 bbox 元组;非法返回 None。""" + if not isinstance(frame, dict): + return None + box = norm_box(frame.get("bbox")) + if box is None or box[2] <= box[0] or box[3] <= box[1]: + return None + return box + + +def frame_world_size(frame): + """图框在 CAD 中的世界尺寸 (W, H);bbox 非法返回 (0.0, 0.0)。""" + box = frame_bbox(frame) + if box is None: + return (0.0, 0.0) + return (box[2] - box[0], box[3] - box[1]) + + +def frame_scale(frame, default=0.0): + """图框出图比例(1:100 → 100.0)。 + + 优先级: + 1. `sheet_scale`(工具已识别,>0 直接用); + 2. 由「图框世界尺寸 / 纸张尺寸」反算(需 `sheet_size` 命中纸张表); + 3. `default`(拿不到 → 调用方退回固定策略)。 + """ + if not isinstance(frame, dict): + return default + try: + s = float(frame.get("sheet_scale") or 0.0) + except (TypeError, ValueError): + s = 0.0 + if s > 0: + return s + + w, h = frame_world_size(frame) + if w <= 0 or h <= 0: + return default + sheet = SHEET_SIZES_MM.get(str(frame.get("sheet_size") or "").strip().upper()) + if not sheet: + return default + sw, sh = sheet + # 判断横放 / 纵放:取长宽比更接近的朝向 + ar = w / h + if abs(ar - sw / sh) <= abs(ar - sh / sw): + return w / sw # 横放:世界宽 ↔ 纸宽 + return w / sh # 纵放:世界宽 ↔ 纸高 + + +def effective_px(view_px, max_edge): + """按长边下采样后的**有效像素** (ew, eh)。 + + 与 `llm_client.downsample_image_b64` 的缩放规则一致(长边缩到 max_edge)。 + 非法输入返回 (0, 0);max_edge<=0 表示不缩放。 + """ + if not view_px or len(view_px) != 2: + return (0, 0) + try: + w, h = int(view_px[0]), int(view_px[1]) + except (TypeError, ValueError): + return (0, 0) + if w <= 0 or h <= 0: + return (0, 0) + if max_edge and max_edge > 0 and max(w, h) > max_edge: + k = max_edge / float(max(w, h)) + return (max(1, int(round(w * k))), max(1, int(round(h * k)))) + return (w, h) + + +def world_per_px(scale, text_mm, readable_px): + """每像素代表的 CAD 图形单位:s = (text_mm * scale) / readable_px。 + + `text_mm` 为纸面字高(国标 2~3mm),`scale` 为出图比例(1:100 → 100), + `readable_px` 为 VLM 可读的**最小像素字高**。任一非正 → 返回 0.0(表示无法规划)。 + """ + try: + scale, text_mm, readable_px = float(scale), float(text_mm), float(readable_px) + except (TypeError, ValueError): + return 0.0 + if scale <= 0 or text_mm <= 0 or readable_px <= 0: + return 0.0 + return text_mm * scale / readable_px + + +def is_text_type(type_name): + """实体类型名是否为「文字/标注类」。 + + 标准实体给 DXF 名(TEXT/MTEXT/ATTRIB/ATTDEF/DIMENSION)→ 精确命中; + 自定义实体兜底为**类名去 AcDb 前缀并大写**,故按**关键词包含**判定 + (TEXT/LABEL/DIM/NOTE/TAG/ANNO/TITLE)。非字符串/空 → False。 + """ + if not isinstance(type_name, str): + return False + t = type_name.strip().upper() + if not t: + return False + if t in _STD_TEXT_TYPES: + return True + return any(k in t for k in _TEXT_KEYWORDS) + + +def boxes_intersect(a, b, eps=0.0): + """两个 bbox 是否相交(接触视为相交;eps 可放宽)。""" + if a is None or b is None: + return False + return not ( + a[2] < b[0] - eps or b[2] < a[0] - eps + or a[3] < b[1] - eps or b[3] < a[1] - eps + ) + + +def cluster_text_flags(clusters, text_boxes): + """标记每个簇是否含文字类实体(bbox 相交即算,保守)。 + + 返回与 `clusters` 等长的 bool 列表。 + """ + boxes = [b for b in (norm_box(x) for x in (text_boxes or [])) if b] + flags = [] + for cl in clusters or []: + b = norm_box(cl.get("bbox")) if isinstance(cl, dict) else None + flags.append(bool(b) and any(boxes_intersect(b, tb) for tb in boxes)) + return flags + + +def tiles_for_box(box, cap_w, cap_h, overlap=0.0): + """覆盖 box 所需的 (cols, rows)。 + + `cap_w`/`cap_h` 为单张截图的世界覆盖范围;`overlap` 为相邻瓦片重叠比例 + (>0 时有效步长缩短,张数增加)。非法输入返回 (1, 1)。 + """ + if box is None: + return (1, 1) + w, h = box[2] - box[0], box[3] - box[1] + if w <= 0 or h <= 0: + return (1, 1) + ov = min(max(float(overlap or 0.0), 0.0), _MAX_OVERLAP) + step_w = float(cap_w) * (1.0 - ov) + step_h = float(cap_h) * (1.0 - ov) + if step_w <= 0 or step_h <= 0: + return (1, 1) + return (max(1, int(math.ceil(w / step_w))), max(1, int(math.ceil(h / step_h)))) + + +def _clamp_window(win, frame_win): + """把窗口夹到图框范围内;夹后退化(宽/高<=0)返回 None。""" + if frame_win is None: + return win + fx1, fy1, fx2, fy2 = frame_win + x1 = max(win["min_x"], fx1) + y1 = max(win["min_y"], fy1) + x2 = min(win["max_x"], fx2) + y2 = min(win["max_y"], fy2) + if x2 <= x1 or y2 <= y1: + return None + return {"min_x": x1, "min_y": y1, "max_x": x2, "max_y": y2} + + +def subdivide(box, cols, rows, overlap=0.0, frame_win=None): + """把 box 等分为 cols×rows 个 zoom 窗口(行优先,row 0 在最上方)。 + + 每张按 `overlap` 比例向外扩张(防边界切字),再夹到 `frame_win`。 + """ + x1, y1, x2, y2 = box + cols, rows = max(1, int(cols)), max(1, int(rows)) + w = (x2 - x1) / cols + h = (y2 - y1) / rows + ov = min(max(float(overlap or 0.0), 0.0), _MAX_OVERLAP) + mx, my = w * ov, h * ov + out = [] + for r in range(rows): + for c in range(cols): + sx1 = x1 + c * w + sy2 = y2 - r * h # row 0 在最上方 + win = { + "min_x": sx1 - mx, "min_y": sy2 - h - my, + "max_x": sx1 + w + mx, "max_y": sy2 + my, + } + clamped = _clamp_window(win, frame_win) + if clamped: + out.append(clamped) + return out + + +def plan_regions(frame, clusters, *, text_clusters=(), s_text=0.0, s_line=0.0, + view_px=(0, 0), max_tiles=36, overlap=0.0, keep=None): + """把图框内的簇规划成一组 zoom 窗口(自适应瓦片)。 + + 参数: + - `frame` :图框(含 bbox / sheet_size / sheet_scale); + - `clusters` :`heatmap.cluster_cells` 的输出(已按实体计数降序); + - `text_clusters`:含文字类实体的**簇下标**集合(决定用 s_text 还是 s_line); + - `s_text`/`s_line`:CAD 图形单位 / 像素(`world_per_px` 的输出); + - `view_px` :单张截图的**有效像素** (ew, eh)(`effective_px` 的输出); + - `max_tiles` :返回窗口数硬上界(文字簇优先保留); + - `overlap` :相邻瓦片重叠比例; + - `keep` :可选谓词 `f(window) -> bool`,返回 False 的瓦片被丢弃。 + 用于按**占用格**剔除簇 bbox 内的空白瓦片(簇 bbox 对 L 形/斜向分布含大片空白)。 + + 返回行优先的 window 列表;**无法规划时返回 []**(调用方退回固定策略)。 + """ + frame_win = frame_bbox(frame) + if frame_win is None: + return [] + ew, eh = view_px[0] if view_px else 0, view_px[1] if view_px and len(view_px) > 1 else 0 + if ew <= 0 or eh <= 0: + return [] + + text_idx = set(text_clusters or ()) + # 文字簇优先(同级内保持输入的计数降序) + ordered = sorted( + enumerate(clusters or []), + key=lambda p: (0 if p[0] in text_idx else 1, p[0]), + ) + + regions = [] + for i, cl in ordered: + if len(regions) >= max_tiles: + break + box = norm_box(cl.get("bbox")) if isinstance(cl, dict) else None + if box is None: + continue + s = s_text if i in text_idx else s_line + if s <= 0: + continue + cap_w, cap_h = s * ew, s * eh + cols, rows = tiles_for_box(box, cap_w, cap_h, overlap) + # 单屏可覆盖 → 1 张(subdivide 仍会按 overlap 外扩并夹到图框) + for win in subdivide(box, cols, rows, overlap, frame_win): + if keep is not None and not keep(win): + continue # 该瓦片落在空白区,不出图 + regions.append(win) + if len(regions) >= max_tiles: + break + return regions[:max_tiles] diff --git a/src/cad_mcp_frame/cad-mcp-frame.json b/src/cad_mcp_frame/cad-mcp-frame.json index 3651ee6..2700fc9 100644 --- a/src/cad_mcp_frame/cad-mcp-frame.json +++ b/src/cad_mcp_frame/cad-mcp-frame.json @@ -50,6 +50,26 @@ "factory_function": "CreateViewportScreenshotTool" } }, +{ + "name": "set_drawing_area", + "description": "设置绘图区为最大化状态,显著提升截图像素与细节可读性。mode=max 时最大化 CAD 主窗口并进入干净屏幕(隐藏功能区/命令行/面板);mode=restore 时还原。截图取证前调用 max、结束后必须调用 restore。", + "inputSchema": { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": ["max", "restore"], + "description": "max=最大化绘图区(最大化主窗口并进入干净屏幕,隐藏功能区/命令行/面板);restore=还原调用前的窗口位置与干净屏幕状态" + } + }, + "required": ["mode"] + }, + "backend": { + "type": "c++_dll", + "dll_name": "cad_mcp_plugins.dll", + "factory_function": "CreateSetDrawingAreaTool" + } +}, { "name": "get_drawing_info", "description": "获取当前图纸的元数据:文件名、单位、布局数量、各空间实体总数。审查流程的第一步。", diff --git a/src/cad_mcp_plugins/cmakelists.txt b/src/cad_mcp_plugins/cmakelists.txt index 4d98ce7..5df98cb 100644 --- a/src/cad_mcp_plugins/cmakelists.txt +++ b/src/cad_mcp_plugins/cmakelists.txt @@ -14,6 +14,7 @@ set(SOURCES "dwg_review_frame.cpp" "dwg_review_check.cpp" "dwg_review_view.cpp" + "dwg_review_ui.cpp" "dwg_annotate_common.cpp" "dwg_annotate_draw.cpp" "dwg_annotate_edit.cpp" diff --git a/src/cad_mcp_plugins/dwg_review_ui.cpp b/src/cad_mcp_plugins/dwg_review_ui.cpp new file mode 100644 index 0000000..1982407 --- /dev/null +++ b/src/cad_mcp_plugins/dwg_review_ui.cpp @@ -0,0 +1,192 @@ +// ============================================================================ +// dwg_review_ui.cpp +// 图纸审查 - 绘图区界面控制工具(c++_dll 后端) +// set_drawing_area : 最大化绘图区(最大化主窗口 + 进入「干净屏幕」)与还原 +// +// 背景:截图质量与「绘图区像素」直接相关。实测 CAD 主窗口未最大化时,绘图区只有 +// 2110x462 —— 竖向被标题栏 / 功能区 / 命令行 / 状态栏吃掉约 500px。最大化窗口并进入 +// 干净屏幕(隐藏功能区 / 命令行 / 面板)后绘图区显著变大;同样按长边 1280 下采样时 +// 有效像素更多、视口宽高比更接近图纸,自适应瓦片张数更少。 +// +// 实测(AutoCAD 2020,屏幕 3440x1440,测试图纸)绘图区像素: +// 非最大化 / 无干净屏幕 : 2110 x 462 +// 最大化 + 干净屏幕 : 3310 x 1084 → 面积 3.68x +// +// 重要:本工具会**改动用户的 CAD 界面**(窗口位置 + 干净屏幕状态)。 +// 调用方必须在 finally 里以 mode="restore" 还原;还原信息在本进程内静态保存。 +// 注意 restore 的窗口几何是**延迟**压回的(CLEANSCREENOFF 异步,见 restore 分支), +// 调用方在 restore 后应等待约 0.8s 再认为界面已还原。 +// ============================================================================ + +#include "cadsdk.h" +#include "dwg_review_common.h" +#include "mcp_plugin_api.h" + +#include +#include + +using namespace dwg_review; + +namespace +{ + // 进程内保存的还原信息(工具调用跨请求,故用静态变量)。 + struct SavedUiState + { + bool valid; + bool cleanWasOn; + WINDOWPLACEMENT place; + + SavedUiState() : valid(false), cleanWasOn(false) + { + place.length = sizeof(WINDOWPLACEMENT); + } + }; + SavedUiState g_saved; + + // 待延迟还原的窗口(见 restore 分支:CLEANSCREENOFF 是异步命令, + // AutoCAD 处理它时会按「进入干净屏幕时的布局」重设窗口几何, + // 覆盖掉我们同步执行的 SetWindowPlacement,故需延后一段时间再压回去)。 + HWND g_pendingRestore = NULL; + + // 把保存的窗口布局压回去(幂等;无保存信息则空操作)。 + void applySavedPlacement() + { + if (g_pendingRestore && ::IsWindow(g_pendingRestore) && g_saved.valid) + ::SetWindowPlacement(g_pendingRestore, &g_saved.place); + g_pendingRestore = NULL; + g_saved.valid = false; + } + + void CALLBACK delayedRestoreProc(HWND, UINT, UINT_PTR idEvent, DWORD) + { + ::KillTimer(NULL, idEvent); + applySavedPlacement(); + } + + // CAD 主框架窗口:优先 adsw_acadMainWnd(),退回绘图视图的根窗口。 + HWND mainFrame() + { + HWND h = adsw_acadMainWnd(); + if (h && ::IsWindow(h)) return h; + CView *v = acedGetAcadDwgView(); + if (v && v->m_hWnd) return ::GetAncestor(v->m_hWnd, GA_ROOT); + return NULL; + } + + // 绘图视图窗口(仅图形区,不含滚动条 / 状态栏)。 + HWND dwgView() + { + CView *v = acedGetAcadDwgView(); + if (v && v->m_hWnd) return v->m_hWnd; + return adsw_acadDocWnd(); + } + + // 发送命令(异步;调用方需等待渲染)。 + void sendCmd(const wchar_t *cmd) + { + if (acDocManager && acDocManager->curDocument()) + acDocManager->sendStringToExecute(acDocManager->curDocument(), cmd); + } + + // 读取整型系统变量;失败返回 fallback。 + int getIntVar(const ACHAR *name, int fallback) + { + struct resbuf rb; + if (acedGetVar(name, &rb) == RTNORM && rb.restype == RTSHORT) + return rb.resval.rint; + return fallback; + } + + nlohmann::json viewSize() + { + nlohmann::json a = nlohmann::json::array(); + HWND h = dwgView(); + RECT rc; + if (!h || !::GetClientRect(h, &rc)) return a; + a.push_back(rc.right - rc.left); + a.push_back(rc.bottom - rc.top); + return a; + } + + nlohmann::json mainRect() + { + nlohmann::json a = nlohmann::json::array(); + HWND h = mainFrame(); + RECT rc; + if (!h || !::GetWindowRect(h, &rc)) return a; + a.push_back(rc.left); + a.push_back(rc.top); + a.push_back(rc.right); + a.push_back(rc.bottom); + return a; + } +} + +class mcp_Tool_SetDrawingArea : public mcp_Tool +{ +public: + virtual nlohmann::json Execute(const nlohmann::json &args) override + { + std::string mode = "max"; + if (args.contains("mode") && args["mode"].is_string()) + mode = args["mode"].get(); + + HWND hMain = mainFrame(); + if (!hMain) + return makeError(kErrBadParam, "cannot locate AutoCAD main window"); + + nlohmann::json r; + + if (mode == "max") + { + // 只保存一次(重复 max 不会覆盖首次的原始状态) + if (!g_saved.valid) + { + g_saved.place.length = sizeof(WINDOWPLACEMENT); + ::GetWindowPlacement(hMain, &g_saved.place); + // CLEANSCREENSTATE 读不到时按「原本没开」处理 + g_saved.cleanWasOn = (getIntVar(L"CLEANSCREENSTATE", 0) != 0); + g_saved.valid = true; + } + if (!::IsZoomed(hMain)) + ::ShowWindow(hMain, SW_MAXIMIZE); + if (!g_saved.cleanWasOn) + sendCmd(L"_.CLEANSCREENON "); + r["mode"] = "max"; + r["clean_before"] = g_saved.cleanWasOn; + } + else if (mode == "restore") + { + if (g_saved.valid) + { + if (!g_saved.cleanWasOn) + { + // 退出干净屏幕是**异步**命令:AutoCAD 随后会按「进入干净屏幕时的布局」 + // 重设窗口几何,把我们同步执行的 SetWindowPlacement 覆盖掉 + // (表现为窗口仍最大化)。故延后 700ms 再压回原始布局。 + sendCmd(L"_.CLEANSCREENOFF "); + g_pendingRestore = hMain; + ::SetTimer(NULL, 0, 700, delayedRestoreProc); + } + else + { + // 原本就在干净屏幕:无异步覆盖,直接同步还原。 + g_pendingRestore = hMain; + applySavedPlacement(); + } + } + r["mode"] = "restore"; + } + else + { + return makeError(kErrBadParam, "mode must be 'max' or 'restore'"); + } + + r["ok"] = true; + r["main_rect"] = mainRect(); + r["view_size"] = viewSize(); // 注意:CLEANSCREENON 异步,刚调用时可能还是旧值 + return makeJsonResult(r); + } +}; + +EXPORT_MCP_TOOL(mcp_Tool_SetDrawingArea, CreateSetDrawingAreaTool) diff --git a/src/cad_mcp_plugins/mcp_plugins.cpp b/src/cad_mcp_plugins/mcp_plugins.cpp index e4e602b..67b447b 100644 --- a/src/cad_mcp_plugins/mcp_plugins.cpp +++ b/src/cad_mcp_plugins/mcp_plugins.cpp @@ -129,6 +129,42 @@ namespace return ::GetForegroundWindow() == hwnd; } + + // 临时把某个整型系统变量置 0,析构时恢复原值(RAII)。 + // 用于截图期间临时隐藏 ViewCube / 视口控件等叠加 UI,避免污染截图内容。 + // 若原值本就为 0(或变量不存在/取值失败),则不做任何改动。 + class TempSysVarOff + { + public: + explicit TempSysVarOff(const ACHAR *name) : m_name(name), m_changed(false), m_old(0) + { + struct resbuf rb; + if (acedGetVar(m_name, &rb) == RTNORM && rb.restype == RTSHORT && rb.resval.rint != 0) + { + m_old = rb.resval.rint; + struct resbuf val; + val.restype = RTSHORT; + val.resval.rint = 0; + m_changed = (acedSetVar(m_name, &val) == RTNORM); + } + } + // 恢复原值;幂等(可显式提前调用,析构时兜底) + void restore() + { + if (!m_changed) return; + struct resbuf val; + val.restype = RTSHORT; + val.resval.rint = m_old; + acedSetVar(m_name, &val); + m_changed = false; + } + ~TempSysVarOff() { restore(); } + + private: + const ACHAR *m_name; + bool m_changed; + int m_old; + }; } class mcp_Tool_ViewportScreenshot: public mcp_Tool @@ -136,8 +172,14 @@ class mcp_Tool_ViewportScreenshot: public mcp_Tool public: virtual nlohmann::json Execute(const nlohmann::json &args) override { - // 1. 获取 AutoCAD 当前文档的绘图区句柄 - HWND hWnd = adsw_acadDocWnd(); + // 1. 获取绘图视图窗口句柄(仅图形区,不含滚动条 / 状态栏等窗口边框)。 + // 优先 acedGetAcadDwgView(),取不到时退回文档窗口 adsw_acadDocWnd()。 + HWND hWnd = NULL; + CView *pDwgView = acedGetAcadDwgView(); + if (pDwgView) + hWnd = pDwgView->m_hWnd; + if (!hWnd) + hWnd = adsw_acadDocWnd(); if (!hWnd) return mcp_Tool_Utility::make_error(-32000, L"无法获取CAD绘图区窗口句柄"); @@ -152,7 +194,16 @@ public: } bool stillOccluded = IsWindowOccluded(hWnd); - // 3. 初始化 GDI+ + // 3. 截图期间临时隐藏叠加 UI:ViewCube(NAVVCUBEDISPLAY) 与视口控件(VPCONTROL)。 + // 这两个系统变量控制绘图区右上角 / 左上角的浮层控件,会污染截图并误导视觉模型。 + // 守卫离开作用域时自动恢复原值,不影响用户的 CAD 界面设置。 + TempSysVarOff hideNavCube(L"NAVVCUBEDISPLAY"); + TempSysVarOff hideVpControl(L"VPCONTROL"); + // GDI 抓屏读的是屏幕像素:必须同步重绘,让叠加 UI 的隐藏立即反映到画面。 + ::RedrawWindow(hWnd, NULL, NULL, + RDW_INVALIDATE | RDW_UPDATENOW | RDW_ALLCHILDREN); + + // 4. 初始化 GDI+ Gdiplus::GdiplusStartupInput gdiplusStartupInput; ULONG_PTR gdiplusToken; Gdiplus::GdiplusStartup(&gdiplusToken, &gdiplusStartupInput, NULL); @@ -160,7 +211,7 @@ public: std::string base64Result; bool success = false; - // 4. 开始 Windows GDI 抓屏逻辑 (使用花括号限定生命周期) + // 5. 开始 Windows GDI 抓屏逻辑 (使用花括号限定生命周期) { RECT rc; GetClientRect(hWnd, &rc); @@ -213,10 +264,16 @@ public: ReleaseDC(hWnd, hdcScreen); } - // 5. 关闭 GDI+ + // 6. 关闭 GDI+ Gdiplus::GdiplusShutdown(gdiplusToken); - // 6. 按照 MCP 多模态协议返回数据 + // 7. 恢复叠加 UI(ViewCube / 视口控件)并重绘一次,让界面回到用户设置的样子 + hideVpControl.restore(); + hideNavCube.restore(); + ::RedrawWindow(hWnd, NULL, NULL, + RDW_INVALIDATE | RDW_UPDATENOW | RDW_ALLCHILDREN); + + // 8. 按照 MCP 多模态协议返回数据 if (success) { std::wstring msg = L"已成功截取 AutoCAD 当前视口画面。"; diff --git a/tests/fakes.py b/tests/fakes.py index f4780b4..720d77f 100644 --- a/tests/fakes.py +++ b/tests/fakes.py @@ -10,6 +10,7 @@ import json +from dwg_review import evidence_model from dwg_review.cad_tools import CadTools from dwg_review.checklist import DEFAULT_CHECKLIST from dwg_review.context import ReviewContext @@ -313,10 +314,23 @@ def make_evidence( bbox=(0.0, 0.0, 100.0, 50.0), image_b64=None, image_mime=None, + images=None, + source_type=None, **over, ): - """构造 collect_frame 的返回值(evidence)。""" + """构造 collect_frame 的返回值(evidence)。 + + 兼容旧的 `image_b64`/`image_mime` 单图入参:若给了 image_b64 而未给 images, + 则归一进 `images` 列表(与 `evidence_model.evidence_images` 的归一保持一致)。 + """ + if images is None: + images = [] + if image_b64: + images.append( + evidence_model.make_image(image_b64, image_mime or "image/png") + ) ev = { + "source_type": source_type or evidence_model.SOURCE_CAD, "index": index, "frame": frame(bbox), "window": { @@ -330,8 +344,7 @@ def make_evidence( "dimensions": {"total": 0, "items": []}, "blocks": {"total": 0, "items": []}, "polylines": {"total": 0, "items": []}, - "image_b64": image_b64, - "image_mime": image_mime, + "images": images, } ev.update(over) return ev diff --git a/tests/test_config.py b/tests/test_config.py index 5b63875..73872d2 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -79,6 +79,41 @@ def test_credibility_constants_have_expected_types(): assert isinstance(config.DUP_GROUP_LIMIT, int) +def test_tiling_constants_have_expected_types(): + assert isinstance(config.TILING_ENABLED, bool) + assert isinstance(config.TEXT_HEIGHT_MM, float) + assert isinstance(config.TEXT_READABLE_PX, int) + assert isinstance(config.LINE_READABLE_PX, int) + assert isinstance(config.TILE_MAX_COUNT, int) + assert isinstance(config.TILE_OVERLAP, float) + assert isinstance(config.HEATMAP_CELL_DIV, int) + assert isinstance(config.HEATMAP_CLUSTER_DILATE, int) + assert isinstance(config.HEATMAP_MAX_AREA_FRAC, float) + + +def test_tiling_defaults_are_sane(): + # 默认启用瓦片规划;可读下限为正;成本上界为正 + assert config.TILING_ENABLED is True + assert config.TEXT_HEIGHT_MM > 0 + assert config.TEXT_READABLE_PX > 0 + assert config.LINE_READABLE_PX > 0 + assert config.TILE_MAX_COUNT > 0 + assert config.HEATMAP_CELL_DIV > 0 + + +def test_drawing_area_constants_have_expected_types(): + assert isinstance(config.DRAWING_AREA_MAXIMIZE, bool) + assert isinstance(config.DRAWING_AREA_ENTER_WAIT, float) + assert isinstance(config.DRAWING_AREA_RESTORE_WAIT, float) + + +def test_drawing_area_defaults_are_sane(): + # 默认开启绘图区最大化;还原等待须盖过插件内 700ms 的延迟还原(实测 1.0s 才收敛) + assert config.DRAWING_AREA_MAXIMIZE is True + assert config.DRAWING_AREA_ENTER_WAIT >= 0 + assert config.DRAWING_AREA_RESTORE_WAIT >= 1.2 + + def test_credibility_defaults_are_on(): # 默认开启,保证默认拿到可信度收益 assert config.DETERMINISTIC_CHECKS is True diff --git a/tests/test_evidence.py b/tests/test_evidence.py index 1964a3d..ef385d7 100644 --- a/tests/test_evidence.py +++ b/tests/test_evidence.py @@ -1,9 +1,30 @@ """`dwg_review.phases.evidence` 单元测试。""" +import base64 +import io + import pytest -from dwg_review.phases.evidence import bbox_to_window, collect_frame -from tests.fakes import FakeMcpClient, frame, make_ctx, ok_image, ok_text +from dwg_review.phases.evidence import ( + _apply_keep, + _content_predicate, + _is_truncated, + _plan_tiles, + _text_boxes, + bbox_to_window, + collect_frame, + maximized_drawing_area, +) +from tests.fakes import ( + STANDARD_TOOLS, + FakeMcpClient, + err, + frame, + make_ctx, + ok_image, + ok_text, + tool_schema, +) def test_bbox_to_window_mapping(): @@ -55,8 +76,8 @@ def test_collect_frame_assembles_evidence(): assert ev["dimensions"]["total"] == 2 assert ev["blocks"]["total"] == 0 assert ev["polylines"]["total"] == 3 - assert ev["image_b64"] == "QUJD" - assert ev["image_mime"] == "image/png" + assert ev["source_type"] == "cad" + assert ev["images"] == [{"b64": "QUJD", "mime": "image/png", "label": "overall"}] def test_collect_frame_calls_expected_tools(): @@ -80,12 +101,328 @@ def test_collect_frame_calls_expected_tools(): def test_collect_frame_without_image(): ctx = make_ctx(client=build_evidence_client(image=ok_text({}))) ev = collect_frame(ctx, 1, frame([0, 0, 10, 10])) - assert ev["image_b64"] is None - assert ev["image_mime"] is None + assert ev["images"] == [] def test_collect_frame_tolerates_empty_structured_data(): ctx = make_ctx(client=FakeMcpClient()) # 全部默认 ok_text({}) ev = collect_frame(ctx, 1, frame([0, 0, 10, 10])) assert ev["entities"] == {} - assert ev["image_b64"] is None + assert ev["images"] == [] + + +def test_sub_windows_2x2(): + from dwg_review.phases.evidence import sub_windows + + win = {"min_x": 0.0, "min_y": 0.0, "max_x": 10.0, "max_y": 10.0} + subs = sub_windows(win, 4) + assert len(subs) == 4 + # 行优先:左上 → 右上 → 左下 → 右下 + assert subs[0] == {"min_x": 0.0, "min_y": 5.0, "max_x": 5.0, "max_y": 10.0} + assert subs[1] == {"min_x": 5.0, "min_y": 5.0, "max_x": 10.0, "max_y": 10.0} + assert subs[2] == {"min_x": 0.0, "min_y": 0.0, "max_x": 5.0, "max_y": 5.0} + assert subs[3] == {"min_x": 5.0, "min_y": 0.0, "max_x": 10.0, "max_y": 5.0} + + +def test_sub_windows_disabled(): + from dwg_review.phases.evidence import sub_windows + + win = {"min_x": 0.0, "min_y": 0.0, "max_x": 10.0, "max_y": 10.0} + assert sub_windows(win, 0) == [] + assert sub_windows(win, 1) == [] + + +def test_collect_frame_captures_multiple_images(monkeypatch): + from dwg_review import config + from dwg_review.phases.evidence import collect_frame + + monkeypatch.setattr(config, "EVIDENCE_DETAIL_TILES", 4) + ctx = make_ctx(client=build_evidence_client()) + ev = collect_frame(ctx, 1, frame([0, 0, 100, 50])) + assert [img["label"] for img in ev["images"]] == [ + "overall", + "tile-1", + "tile-2", + "tile-3", + "tile-4", + ] + + +def test_collect_dispatches_cad_source(): + from dwg_review.phases.evidence import collect + + ctx = make_ctx(client=build_evidence_client()) + ev = collect(ctx, 1, frame([0, 0, 10, 10])) + assert ev is not None + assert ev["source_type"] == "cad" + + +def test_collect_returns_none_for_unsupported_source(): + from dwg_review.phases.evidence import collect + + ctx = make_ctx(client=FakeMcpClient()) + assert collect(ctx, 1, {"source_type": "pdf", "bbox": [0, 0, 1, 1]}) is None + + +# --------------------------------------------------------------------------- +# 自适应瓦片规划 +# --------------------------------------------------------------------------- +@pytest.fixture +def tiling_env(monkeypatch): + """把瓦片相关配置钉死,使用例不受外部环境变量影响。""" + from dwg_review import config + + for name, value in { + "TILING_ENABLED": True, + "TEXT_HEIGHT_MM": 2.0, + "TEXT_READABLE_PX": 12, + "LINE_READABLE_PX": 6, + "TILE_MAX_COUNT": 36, + "TILE_OVERLAP": 0.0, + "HEATMAP_CELL_DIV": 32, + "HEATMAP_CLUSTER_DILATE": 0, + "HEATMAP_MAX_AREA_FRAC": 0.5, + "IMAGE_MAX_EDGE": 1280, + "EVIDENCE_MAX_IMAGES": 5, + "EVIDENCE_DETAIL_TILES": 4, + }.items(): + monkeypatch.setattr(config, name, value) + return config + + +def _png_b64(w=2000, h=1414): + from PIL import Image + + buf = io.BytesIO() + Image.new("RGB", (w, h), "black").save(buf, format="PNG") + return base64.b64encode(buf.getvalue()).decode("ascii") + + +def _tiling_client(b64, entity_type="LINE"): + return FakeMcpClient( + responses={ + "query_entities": ok_text( + {"total": 1, "items": [{"handle": "A", "type": entity_type, + "bbox": [0, 0, 60000, 20000]}]} + ), + "get_text_content": ok_text({"total": 0, "items": []}), + "get_dimensions": ok_text({"total": 0, "items": []}), + "get_block_references": ok_text({"total": 0, "items": []}), + "get_polylines_info": ok_text({"total": 0, "items": []}), + "get_viewport_screenshot": ok_image(b64), + } + ) + + +def test_is_truncated(): + assert _is_truncated({"total": 5, "items": [1]}) is True + assert _is_truncated({"total": 1, "items": [1]}) is False + assert _is_truncated({"items": [1, 2]}) is False + assert _is_truncated(None, {}, {"total": 3, "items": []}) is True + assert _is_truncated() is False + + +def test_text_boxes_includes_std_and_custom(): + entities = {"items": [ + {"type": "LINE", "bbox": [0, 0, 1, 1]}, + {"type": "AECDLABEL", "bbox": [2, 2, 3, 3]}, + {"type": "TEXT", "bbox": [4, 4, 5, 5]}, + ]} + texts = {"items": [{"bbox": [6, 6, 7, 7]}]} + dimensions = {"items": [{"bbox": [8, 8, 9, 9]}]} + boxes = _text_boxes(entities, texts, dimensions) + for expected in ((2, 2, 3, 3), (4, 4, 5, 5), (6, 6, 7, 7), (8, 8, 9, 9)): + assert tuple(float(v) for v in expected) in boxes + assert (0.0, 0.0, 1.0, 1.0) not in boxes # 普通线不算文字 + + +def test_plan_tiles_degrades(tiling_env): + win = {"min_x": 0.0, "min_y": 0.0, "max_x": 100.0, "max_y": 50.0} + f = frame([0, 0, 100, 50], sheet_size="A0", sheet_scale=100.0) + boxes = [(0.0, 0.0, 10.0, 10.0)] + assert _plan_tiles(win, f, boxes, [], False, (100, 50)) != [] # 正常路径非空 + assert _plan_tiles(win, None, boxes, [], False, (100, 50)) == [] # 缺图框 + assert _plan_tiles(win, f, [], [], False, (100, 50)) == [] # 无实体 + assert _plan_tiles(win, f, boxes, [], False, None) == [] # 无像素 + assert _plan_tiles(win, f, boxes, [], True, (100, 50)) == [] # 数据截断 + # 比例未知(sheet_scale=0 且 sheet_size 不在纸张表)→ 无法规划 + unknown = frame([0, 0, 100, 50], sheet_size="ZZ", sheet_scale=0.0) + assert _plan_tiles(win, unknown, boxes, [], False, (100, 50)) == [] + + +def test_plan_tiles_disabled_by_config(tiling_env, monkeypatch): + tiling_env.TILING_ENABLED = False + win = {"min_x": 0.0, "min_y": 0.0, "max_x": 100.0, "max_y": 50.0} + f = frame([0, 0, 100, 50], sheet_size="A0", sheet_scale=100.0) + assert _plan_tiles(win, f, [(0.0, 0.0, 10.0, 10.0)], [], False, (100, 50)) == [] + + +def test_capture_images_tiles_line_cluster(tiling_env): + """线条簇:单张可覆盖 → 2 张瓦片(行优先)。""" + pytest.importorskip("PIL") + ctx = make_ctx(client=_tiling_client(_png_b64())) + ev = collect_frame( + ctx, 1, frame([0, 0, 120000, 40000], sheet_size="A0", sheet_scale=100.0) + ) + assert [i["label"] for i in ev["images"]] == ["overall", "tile-1", "tile-2"] + + +def test_capture_images_tiles_text_cluster_is_finer(tiling_env): + """文字簇用更细尺度 → 瓦片更多,并受 EVIDENCE_MAX_IMAGES 截断。""" + pytest.importorskip("PIL") + ctx = make_ctx(client=_tiling_client(_png_b64(), entity_type="AECDLABEL")) + ev = collect_frame( + ctx, 1, frame([0, 0, 120000, 40000], sheet_size="A0", sheet_scale=100.0) + ) + labels = [i["label"] for i in ev["images"]] + assert labels == ["overall", "tile-1", "tile-2", "tile-3", "tile-4"] # 上限 5 张 + + +def test_capture_images_falls_back_without_scale(tiling_env): + """缺 sheet_size/sheet_scale → 退回固定 2x2 象限,并按内容过滤空白象限。""" + pytest.importorskip("PIL") + client = _tiling_client(_png_b64()) + # 实体完全落在**左下**象限内(不贴边界) + client.responses["query_entities"] = ok_text( + {"total": 1, "items": [{"handle": "A", "type": "LINE", + "bbox": [0, 0, 50000, 15000]}]} + ) + ctx = make_ctx(client=client) + ev = collect_frame(ctx, 1, frame([0, 0, 120000, 40000], sheet_size="ZZ")) + assert [i["label"] for i in ev["images"]] == ["overall", "tile-1"] + + +def test_capture_images_fallback_keeps_all_when_no_boxes(tiling_env): + """拿不到实体 bbox(无法判断内容)→ 回退路径不过滤,四象限全拍。""" + pytest.importorskip("PIL") + client = _tiling_client(_png_b64()) + client.responses["query_entities"] = ok_text({"total": 0, "items": []}) + ctx = make_ctx(client=client) + ev = collect_frame(ctx, 1, frame([0, 0, 120000, 40000], sheet_size="ZZ")) + assert [i["label"] for i in ev["images"]] == [ + "overall", "tile-1", "tile-2", "tile-3", "tile-4", + ] + + +def test_capture_images_falls_back_when_truncated(tiling_env): + """实体被截断 → 禁用内容过滤,退回固定策略。""" + pytest.importorskip("PIL") + client = _tiling_client(_png_b64()) + client.responses["query_entities"] = ok_text( + {"total": 500, "items": [{"handle": "A", "type": "LINE", + "bbox": [0, 0, 60000, 20000]}]} + ) + ctx = make_ctx(client=client) + ev = collect_frame( + ctx, 1, frame([0, 0, 120000, 40000], sheet_size="A0", sheet_scale=100.0) + ) + assert [i["label"] for i in ev["images"]] == [ + "overall", "tile-1", "tile-2", "tile-3", "tile-4", + ] + + +# --------------------------------------------------------------------------- +# 内容过滤 / 绘图区最大化 +# --------------------------------------------------------------------------- +def test_apply_keep_filters_and_falls_back(): + regions = [ + {"min_x": 0.0, "min_y": 0.0, "max_x": 1.0, "max_y": 1.0}, + {"min_x": 1.0, "min_y": 1.0, "max_x": 2.0, "max_y": 2.0}, + ] + keep_first = lambda w: w["min_x"] == 0.0 # noqa: E731 + assert _apply_keep(regions, keep_first) == regions[:1] + assert _apply_keep(regions, None) == regions # 无谓词 → 原样 + assert _apply_keep(regions, lambda w: False) == regions # 全被滤掉 → 退回原集合 + assert _apply_keep([], keep_first) == [] + + +def test_content_predicate_none_without_boxes(): + assert _content_predicate([]) is None + assert _content_predicate(None) is None + + +def test_content_predicate_detects_intersection(): + keep = _content_predicate([(0.0, 0.0, 5.0, 5.0)]) + assert keep({"min_x": 4.0, "min_y": 4.0, "max_x": 9.0, "max_y": 9.0}) is True + assert keep({"min_x": 6.0, "min_y": 6.0, "max_x": 9.0, "max_y": 9.0}) is False + + +@pytest.fixture +def drawing_area_env(monkeypatch): + """打开绘图区最大化,并把等待时间归零(用例不真的 sleep)。""" + from dwg_review import config + + monkeypatch.setattr(config, "DRAWING_AREA_MAXIMIZE", True) + monkeypatch.setattr(config, "DRAWING_AREA_ENTER_WAIT", 0.0) + monkeypatch.setattr(config, "DRAWING_AREA_RESTORE_WAIT", 0.0) + return config + + +def _area_client(responses=None): + """带 set_drawing_area 工具的假客户端。""" + return FakeMcpClient( + tools=STANDARD_TOOLS + [tool_schema("set_drawing_area", "绘图区最大化")], + responses=responses, + ) + + +def test_maximized_drawing_area_calls_max_then_restore(drawing_area_env): + client = _area_client() + ctx = make_ctx(client=client) + with maximized_drawing_area(ctx): + assert client.called_names() == ["set_drawing_area"] # 进入时只 max + assert [c["arguments"] for c in client.called("set_drawing_area")] == [ + {"mode": "max"}, {"mode": "restore"}, + ] + + +def test_maximized_drawing_area_degrades_without_tool(drawing_area_env): + """旧版插件没有该工具 → 静默降级,一次调用都不发。""" + client = FakeMcpClient() + ctx = make_ctx(client=client) + with maximized_drawing_area(ctx): + pass + assert client.calls == [] + + +def test_maximized_drawing_area_disabled_by_config(drawing_area_env): + drawing_area_env.DRAWING_AREA_MAXIMIZE = False + client = _area_client() + ctx = make_ctx(client=client) + with maximized_drawing_area(ctx): + pass + assert client.calls == [] + + +def test_maximized_drawing_area_restores_even_if_body_raises(drawing_area_env): + client = _area_client() + ctx = make_ctx(client=client) + with pytest.raises(RuntimeError): + with maximized_drawing_area(ctx): + raise RuntimeError("boom") + assert client.arguments_of("set_drawing_area") == {"mode": "restore"} + + +def test_maximized_drawing_area_degrades_when_max_fails(drawing_area_env): + """max 调用失败 → 视为没进入最大化,不再发 restore,也不抛异常。""" + client = _area_client(responses={"set_drawing_area": err("no window")}) + ctx = make_ctx(client=client) + with maximized_drawing_area(ctx): + pass + assert [c["arguments"] for c in client.called("set_drawing_area")] == [{"mode": "max"}] + + +def test_maximized_drawing_area_restore_failure_does_not_mask_body(drawing_area_env): + """restore 失败不应掩盖业务异常。""" + client = _area_client() + ctx = make_ctx(client=client) + + def boom(args): + if args.get("mode") == "restore": + raise RuntimeError("restore failed") + return ok_text({}) + + client.responses["set_drawing_area"] = boom + with pytest.raises(ValueError): + with maximized_drawing_area(ctx): + raise ValueError("business error") diff --git a/tests/test_evidence_model.py b/tests/test_evidence_model.py new file mode 100644 index 0000000..22ffa7b --- /dev/null +++ b/tests/test_evidence_model.py @@ -0,0 +1,100 @@ +"""`dwg_review.evidence_model` 单元测试。""" + +from dwg_review import evidence_model as em + + +def test_make_image_defaults(): + assert em.make_image("QUJD") == {"b64": "QUJD", "mime": "image/png", "label": None} + + +def test_make_image_custom(): + assert em.make_image("QUJD", "image/jpeg", "tile-1") == { + "b64": "QUJD", + "mime": "image/jpeg", + "label": "tile-1", + } + + +def test_make_image_empty_mime_falls_back(): + assert em.make_image("x", "")["mime"] == "image/png" + + +def test_source_type_of_default_cad(): + assert em.source_type_of({}) == em.SOURCE_CAD + assert em.source_type_of(None) == em.SOURCE_CAD + assert em.source_type_of({"source_type": ""}) == em.SOURCE_CAD + + +def test_source_type_of_explicit(): + assert em.source_type_of({"source_type": "pdf"}) == "pdf" + assert em.source_type_of({"source_type": "image"}) == "image" + + +def test_evidence_images_new_form(): + ev = { + "images": [ + em.make_image("A", label="overall"), + em.make_image("B", label="tile-1"), + ] + } + out = em.evidence_images(ev) + assert [i["b64"] for i in out] == ["A", "B"] + assert [i["label"] for i in out] == ["overall", "tile-1"] + + +def test_evidence_images_filters_empty_b64(): + ev = {"images": [{"b64": "", "mime": "image/png"}, {"b64": "A"}]} + out = em.evidence_images(ev) + assert len(out) == 1 + assert out[0]["b64"] == "A" + + +def test_evidence_images_legacy_fallback(): + ev = {"image_b64": "LEG", "image_mime": "image/jpeg"} + assert em.evidence_images(ev) == [ + {"b64": "LEG", "mime": "image/jpeg", "label": None} + ] + + +def test_evidence_images_legacy_default_mime(): + assert em.evidence_images({"image_b64": "LEG"})[0]["mime"] == "image/png" + + +def test_evidence_images_empty(): + assert em.evidence_images({}) == [] + assert em.evidence_images(None) == [] + assert em.evidence_images({"images": []}) == [] + + +def test_evidence_images_prefers_new_over_legacy(): + ev = {"images": [em.make_image("NEW")], "image_b64": "OLD"} + assert [i["b64"] for i in em.evidence_images(ev)] == ["NEW"] + + +def test_primary_image_prefers_overall(): + ev = { + "images": [ + em.make_image("A", label="tile-1"), + em.make_image("B", label="overall"), + ] + } + assert em.primary_image(ev)["b64"] == "B" + + +def test_primary_image_first_when_no_overall(): + ev = {"images": [em.make_image("A", label="tile-1"), em.make_image("B")]} + assert em.primary_image(ev)["b64"] == "A" + + +def test_primary_image_none_when_empty(): + assert em.primary_image({}) is None + + +def test_image_labels(): + ev = { + "images": [ + em.make_image("A", label="overall"), + em.make_image("B", label="tile-1"), + ] + } + assert em.image_labels(ev) == ["overall", "tile-1"] diff --git a/tests/test_heatmap.py b/tests/test_heatmap.py new file mode 100644 index 0000000..eeaf7a6 --- /dev/null +++ b/tests/test_heatmap.py @@ -0,0 +1,173 @@ +"""`dwg_review.heatmap` 单元测试(纯函数,不依赖 CAD / LLM / Pillow)。""" + +from dwg_review import heatmap as hm + +WIN = {"min_x": 0.0, "min_y": 0.0, "max_x": 100.0, "max_y": 100.0} + + +def _grid(boxes, cell=50.0): + return hm.build_grid(WIN, boxes, cell) + + +# --- norm_box / entity_boxes ------------------------------------------------ + + +def test_norm_box_valid(): + assert hm.norm_box([0, 0, 10, 20]) == (0.0, 0.0, 10.0, 20.0) + + +def test_norm_box_swaps_reversed(): + assert hm.norm_box([10, 20, 0, 0]) == (0.0, 0.0, 10.0, 20.0) + + +def test_norm_box_invalid(): + assert hm.norm_box([1, 2, 3]) is None + assert hm.norm_box("x") is None + assert hm.norm_box(None) is None + assert hm.norm_box([1, 2, 3, "a"]) is None + assert hm.norm_box([0, 0, float("nan"), 1]) is None + + +def test_entity_boxes_accepts_single_envelope(): + env = {"items": [{"bbox": [0, 0, 1, 1]}, {"bbox": [2, 2, 3, 3]}], "total": 2} + assert hm.entity_boxes(env) == [(0.0, 0.0, 1.0, 1.0), (2.0, 2.0, 3.0, 3.0)] + + +def test_entity_boxes_accepts_iterable_and_skips_invalid(): + envs = [ + {"items": [{"bbox": [0, 0, 1, 1]}, {"nope": 1}, "junk"]}, + {"items": "not-a-list"}, + None, + {"items": [{"bbox": [5, 5, 6, 6]}]}, + ] + assert hm.entity_boxes(envs) == [(0.0, 0.0, 1.0, 1.0), (5.0, 5.0, 6.0, 6.0)] + + +def test_entity_boxes_empty(): + assert hm.entity_boxes(None) == [] + assert hm.entity_boxes({}) == [] + assert hm.entity_boxes({"items": []}) == [] + + +# --- box helpers ------------------------------------------------------------ + + +def test_box_area_and_union(): + assert hm.box_area((0, 0, 10, 5)) == 50.0 + assert hm.box_area((10, 0, 0, 5)) == 0.0 # 退化(未归一) + assert hm.union_box([(0, 0, 10, 10), (5, 5, 20, 30)]) == (0.0, 0.0, 20.0, 30.0) + assert hm.union_box([]) is None + + +def test_div_to_cell_uses_long_edge(): + assert hm.div_to_cell(WIN, 4) == 25.0 + + +# --- build_grid ------------------------------------------------------------- + + +def test_build_grid_shape_and_left_half(): + g = _grid([(0, 0, 50, 100)]) + assert (g["cols"], g["rows"]) == (2, 2) + assert (g["cell_w"], g["cell_h"]) == (50.0, 50.0) + # 左半格两行各命中一次,右半格不命中(恰好落在 x=50 边界上不算越界) + assert [row[0] for row in g["counts"]] == [1, 1] + assert [row[1] for row in g["counts"]] == [0, 0] + assert g["coverage"][0][0] == 1.0 + assert g["coverage"][1][0] == 1.0 + + +def test_build_grid_row0_is_top(): + g = _grid([(0, 60, 10, 70)]) + assert g["counts"][0][0] == 1 + assert g["counts"][1][0] == 0 + + +def test_build_grid_degenerate_horizontal_line_counted(): + # 高度为 0 的线(bbox 退化)也要能被计入,且横跨两列 + g = _grid([(0, 25, 100, 25)]) + assert g["counts"][1] == [1, 1] + assert g["counts"][0] == [0, 0] + + +def test_build_grid_ignores_boxes_outside_window(): + g = _grid([(200, 200, 300, 300)]) + assert sum(sum(row) for row in g["counts"]) == 0 + + +def test_build_grid_invalid_window(): + g = hm.build_grid({"min_x": 0, "min_y": 0, "max_x": 0, "max_y": 10}, [], 5) + assert g["cols"] == 0 and g["rows"] == 0 and g["counts"] == [] + + +# --- occupied / cluster ----------------------------------------------------- + + +def test_occupied_cells_row_major(): + g = _grid([(0, 0, 50, 100)]) + assert hm.occupied_cells(g) == [(0, 0), (1, 0)] + + +def test_cluster_cells_separate_diagonal(): + g = _grid([(5, 5, 15, 15), (85, 85, 95, 95)]) + assert sorted(hm.occupied_cells(g)) == [(0, 1), (1, 0)] + clusters = hm.cluster_cells(g) + assert len(clusters) == 2 # 对角相邻不算连通 + boxes = sorted(cl["bbox"] for cl in clusters) + assert boxes == [(0.0, 0.0, 50.0, 50.0), (50.0, 50.0, 100.0, 100.0)] + + +def test_cluster_cells_dilate_merges_nearby(): + g = _grid([(5, 5, 15, 15), (85, 85, 95, 95)]) + clusters = hm.cluster_cells(g, dilate=1) + assert len(clusters) == 1 + # 两格合并后 bbox 为两真实占用格的并集(膨胀出来的空格不参与) + assert clusters[0]["bbox"] == (0.0, 0.0, 100.0, 100.0) + + +def test_cluster_cells_sorted_by_count_desc(): + # 左下格被 3 个实体命中,右上格 1 个 + g = _grid([(0, 0, 10, 10), (0, 20, 10, 30), (0, 40, 10, 50), (90, 90, 100, 100)]) + clusters = hm.cluster_cells(g) + assert clusters[0]["count"] == 3 + assert clusters[0]["bbox"] == (0.0, 0.0, 50.0, 50.0) + + +def test_cluster_cells_empty_grid(): + assert hm.cluster_cells(_grid([])) == [] + + +# --- windows / stats -------------------------------------------------------- + + +def test_grid_to_windows_applies_margin(): + clusters = [{"bbox": (0.0, 0.0, 100.0, 50.0), "cells": [], "count": 1}] + (win,) = hm.grid_to_windows(clusters, margin=0.1) + assert win == {"min_x": -10.0, "min_y": -5.0, "max_x": 110.0, "max_y": 55.0} + + +def test_grid_to_windows_preserves_order(): + clusters = [ + {"bbox": (0.0, 0.0, 1.0, 1.0), "cells": [], "count": 9}, + {"bbox": (2.0, 2.0, 3.0, 3.0), "cells": [], "count": 1}, + ] + wins = hm.grid_to_windows(clusters) + assert [w["min_x"] for w in wins] == [0.0, 2.0] + + +def test_grid_stats(): + g = _grid([(0, 0, 50, 100)]) + clusters = hm.cluster_cells(g) + st = hm.grid_stats(g, clusters) + assert st["rows"] == 2 and st["cols"] == 2 + assert st["occupied"] == 2 + assert st["occupied_ratio"] == 0.5 + assert st["max_count"] == 1 + assert st["clusters"] == 1 + assert st["max_cluster_cells"] == 2 + + +def test_grid_stats_without_clusters(): + st = hm.grid_stats(_grid([])) + assert st["occupied"] == 0 + assert "clusters" not in st diff --git a/tests/test_llm_client.py b/tests/test_llm_client.py index f4e554c..73886d5 100644 --- a/tests/test_llm_client.py +++ b/tests/test_llm_client.py @@ -186,3 +186,31 @@ def test_downsample_bad_base64_returns_original(): pytest.importorskip("PIL") bad = base64.b64encode(b"this is not an image").decode("ascii") assert downsample_image_b64(bad, "image/png", max_edge=10) == (bad, "image/png") + + +# --------------------------------------------------------------------------- +# 图片尺寸探测(瓦片规划用) +# --------------------------------------------------------------------------- +def test_image_size_returns_none_without_pil(monkeypatch): + monkeypatch.setattr(llm_module, "HAS_PIL", False) + assert llm_module.image_size_b64("QUJD") is None + + +def test_image_size_returns_none_on_empty(): + assert llm_module.image_size_b64("") is None + + +def test_image_size_returns_none_on_bad_data(): + pytest.importorskip("PIL") + bad = base64.b64encode(b"not an image").decode("ascii") + assert llm_module.image_size_b64(bad) is None + + +def test_image_size_reads_dimensions(): + pytest.importorskip("PIL") + from PIL import Image + + buf = io.BytesIO() + Image.new("RGB", (321, 123), "white").save(buf, format="PNG") + b64 = base64.b64encode(buf.getvalue()).decode("ascii") + assert llm_module.image_size_b64(b64) == (321, 123) diff --git a/tests/test_review.py b/tests/test_review.py index 8ba87fd..5ce2d40 100644 --- a/tests/test_review.py +++ b/tests/test_review.py @@ -28,8 +28,8 @@ from tests.fakes import ( def test_evidence_payload_strips_image(): ev = make_evidence(image_b64="QUJD", image_mime="image/png") payload = evidence_payload(ev) + assert "images" not in payload assert "image_b64" not in payload - assert "image_mime" not in payload assert payload["frame_index"] == 1 @@ -243,3 +243,20 @@ def test_review_frame_intermediate_tool_turn_json_not_parsed(): out = review_frame(ctx, make_evidence(), "sys", log=lambda *a: None) assert out["summary"] == "最终结论" assert out["issues"] == [] + + +def test_review_frame_includes_multiple_image_blocks(): + llm = FakeLlmClient(script=[llm_text("ok")]) + ctx = make_ctx(client=FakeMcpClient(), llm=llm) + ev = make_evidence( + images=[ + {"b64": "A", "mime": "image/png", "label": "overall"}, + {"b64": "B", "mime": "image/png", "label": "tile-1"}, + ] + ) + review_frame(ctx, ev, "sys", log=lambda *a: None) + user_content = llm.calls[0]["messages"][1]["content"] + image_blocks = [b for b in user_content if b.get("type") == "image_url"] + assert len(image_blocks) == 2 + assert any(b.get("text") == "(截图:overall)" for b in user_content) + assert any(b.get("text") == "(截图:tile-1)" for b in user_content) diff --git a/tests/test_tiling.py b/tests/test_tiling.py new file mode 100644 index 0000000..d7b6b22 --- /dev/null +++ b/tests/test_tiling.py @@ -0,0 +1,223 @@ +"""`dwg_review.tiling` 单元测试(纯函数,不依赖 CAD / LLM / Pillow)。""" + +from dwg_review import tiling as tl + + +def _frame(bbox, sheet_size="A0", sheet_scale=0.0): + return {"bbox": list(bbox), "sheet_size": sheet_size, "sheet_scale": sheet_scale} + + +def _cluster(bbox, count=10): + return {"bbox": tuple(float(v) for v in bbox), "cells": [], "count": count} + + +# --- norm_box / frame_bbox -------------------------------------------------- + +def test_norm_box_valid_and_invalid(): + assert tl.norm_box([0, 0, 10, 20]) == (0.0, 0.0, 10.0, 20.0) + assert tl.norm_box([10, 20, 0, 0]) == (0.0, 0.0, 10.0, 20.0) + assert tl.norm_box([1, 2, 3]) is None + assert tl.norm_box(None) is None + assert tl.norm_box([0, 0, float("nan"), 1]) is None + + +def test_frame_bbox_rejects_degenerate_and_non_dict(): + assert tl.frame_bbox(_frame((0, 0, 100, 50))) == (0.0, 0.0, 100.0, 50.0) + assert tl.frame_bbox(_frame((0, 0, 0, 50))) is None # 零宽 + assert tl.frame_bbox(_frame((0, 0, 100, 0))) is None # 零高 + assert tl.frame_bbox({"bbox": [1, 2, 3]}) is None + assert tl.frame_bbox(None) is None + + +def test_frame_world_size(): + assert tl.frame_world_size(_frame((0, 0, 100, 50))) == (100.0, 50.0) + assert tl.frame_world_size(_frame((0, 0, 0, 0))) == (0.0, 0.0) + + +# --- frame_scale:三条路径 -------------------------------------------------- + +def test_frame_scale_prefers_sheet_scale(): + assert tl.frame_scale(_frame((0, 0, 100, 50), sheet_scale=100.0)) == 100.0 + + +def test_frame_scale_derived_landscape(): + # A0 横放:世界 118900 x 84100 → 118900/1189 = 100 + f = _frame((0, 0, 118900, 84100), sheet_size="A0") + assert tl.frame_scale(f) == 100.0 + + +def test_frame_scale_derived_portrait(): + # A0 纵放:世界宽 ↔ 纸高 841 + f = _frame((0, 0, 84100, 118900), sheet_size="A0") + assert tl.frame_scale(f) == 100.0 + + +def test_frame_scale_falls_back_to_default(): + assert tl.frame_scale(_frame((0, 0, 100, 50), sheet_size="ZZ"), default=7.0) == 7.0 + assert tl.frame_scale(_frame((0, 0, 0, 0)), default=7.0) == 7.0 + assert tl.frame_scale(None, default=7.0) == 7.0 + + +# --- effective_px / world_per_px ------------------------------------------- + +def test_effective_px_no_resize_when_small(): + assert tl.effective_px((800, 600), 1280) == (800, 600) + + +def test_effective_px_downsamples_long_edge(): + # 实测 CAD 绘图区 2110x462 → 长边缩到 1280 + assert tl.effective_px((2110, 462), 1280) == (1280, 280) + + +def test_effective_px_invalid(): + assert tl.effective_px(None, 1280) == (0, 0) + assert tl.effective_px((0, 100), 1280) == (0, 0) + assert tl.effective_px((100, 100), 0) == (100, 100) # 0 = 不缩放 + + +def test_world_per_px(): + # 1:100、纸面 2mm、VLM 需 10px → 每像素 20 图形单位 + assert tl.world_per_px(100.0, 2.0, 10.0) == 20.0 + assert tl.world_per_px(0, 2.0, 10.0) == 0.0 + assert tl.world_per_px(100.0, 2.0, 0) == 0.0 + + +# --- is_text_type ----------------------------------------------------------- + +def test_is_text_type_standard(): + for t in ("TEXT", "MTEXT", "ATTRIB", "ATTDEF", "DIMENSION", "text"): + assert tl.is_text_type(t) is True + + +def test_is_text_type_custom_keywords(): + for t in ("AECDLABEL", "AECDDIMTEXT", "MYNOTE", "PARTTAG", "ANNOTATION", "TITLEBLK"): + assert tl.is_text_type(t) is True + + +def test_is_text_type_negative(): + for t in ("AECDBEWPIPE", "LINE", "LWPOLYLINE", "INSERT", "HATCH", "", None, 123): + assert tl.is_text_type(t) is False + + +# --- 相交 / 文字簇标记 ------------------------------------------------------- + +def test_boxes_intersect(): + assert tl.boxes_intersect((0, 0, 10, 10), (5, 5, 15, 15)) is True + assert tl.boxes_intersect((0, 0, 10, 10), (10, 10, 20, 20)) is True # 接触算相交 + assert tl.boxes_intersect((0, 0, 10, 10), (11, 11, 20, 20)) is False + assert tl.boxes_intersect(None, (0, 0, 1, 1)) is False + + +def test_cluster_text_flags(): + clusters = [_cluster((0, 0, 10, 10)), _cluster((100, 100, 110, 110))] + flags = tl.cluster_text_flags(clusters, [(5, 5, 6, 6)]) + assert flags == [True, False] + assert tl.cluster_text_flags(clusters, []) == [False, False] + assert tl.cluster_text_flags([], [(0, 0, 1, 1)]) == [] + + +# --- tiles_for_box / subdivide --------------------------------------------- + +def test_tiles_for_box_fits_single(): + assert tl.tiles_for_box((0, 0, 1000, 500), 1000, 500) == (1, 1) + + +def test_tiles_for_box_dense(): + assert tl.tiles_for_box((0, 0, 2000, 1000), 1000, 500) == (2, 2) + + +def test_tiles_for_box_overlap_increases_count(): + assert tl.tiles_for_box((0, 0, 1000, 500), 1000, 500, overlap=0.1) == (2, 2) + + +def test_tiles_for_box_invalid(): + assert tl.tiles_for_box(None, 10, 10) == (1, 1) + assert tl.tiles_for_box((0, 0, 0, 0), 10, 10) == (1, 1) + assert tl.tiles_for_box((0, 0, 10, 10), 0, 10) == (1, 1) + + +def test_subdivide_row_major_top_first(): + wins = tl.subdivide((0, 0, 100, 100), 2, 2) + assert len(wins) == 4 + assert wins[0] == {"min_x": 0.0, "min_y": 50.0, "max_x": 50.0, "max_y": 100.0} + assert wins[1] == {"min_x": 50.0, "min_y": 50.0, "max_x": 100.0, "max_y": 100.0} + assert wins[2] == {"min_x": 0.0, "min_y": 0.0, "max_x": 50.0, "max_y": 50.0} + assert wins[3] == {"min_x": 50.0, "min_y": 0.0, "max_x": 100.0, "max_y": 50.0} + + +def test_subdivide_overlap_expands_then_clamps(): + wins = tl.subdivide((0, 0, 100, 100), 2, 2, overlap=0.1, frame_win=(0, 0, 100, 100)) + # 每格 50x50,外扩 5 → 左上格:x 0..55(左被夹到 0),y 45..100(上被夹到 100) + assert wins[0] == {"min_x": 0.0, "min_y": 45.0, "max_x": 55.0, "max_y": 100.0} + + +# --- plan_regions ----------------------------------------------------------- + +def test_plan_regions_sparse_single_tile(): + frame = _frame((0, 0, 1000, 500)) + clusters = [_cluster((100, 100, 400, 300))] + wins = tl.plan_regions(frame, clusters, s_line=10.0, view_px=(100, 50)) + assert len(wins) == 1 + assert wins[0]["min_x"] <= 100 and wins[0]["max_x"] >= 400 + + +def test_plan_regions_dense_splits(): + frame = _frame((0, 0, 2000, 1000)) + clusters = [_cluster((0, 0, 2000, 1000))] + wins = tl.plan_regions(frame, clusters, s_line=10.0, view_px=(100, 50)) + assert len(wins) == 4 # 2x2 + + +def test_plan_regions_text_uses_finer_scale(): + frame = _frame((0, 0, 1000, 500)) + clusters = [_cluster((0, 0, 1000, 500))] + line_only = tl.plan_regions(frame, clusters, s_line=10.0, view_px=(100, 50)) + text = tl.plan_regions(frame, clusters, text_clusters={0}, s_text=5.0, + s_line=10.0, view_px=(100, 50)) + assert len(line_only) == 1 + assert len(text) == 4 # 半径减半 → 2x2 + + +def test_plan_regions_text_cluster_prioritized_on_truncation(): + frame = _frame((0, 0, 4000, 2000)) + clusters = [_cluster((0, 0, 2000, 1000), count=99), # 非文字,排前面(输入序) + _cluster((2000, 1000, 4000, 2000), count=1)] # 文字 + wins = tl.plan_regions(frame, clusters, text_clusters={1}, + s_text=10.0, s_line=10.0, view_px=(100, 50), max_tiles=2) + assert len(wins) == 2 + # 文字簇(下标 1,位于右上)应优先出图 + assert wins[0]["min_x"] >= 2000 and wins[0]["min_y"] >= 1000 + + +def test_plan_regions_keep_filters_empty_tiles(): + """`keep` 谓词按占用情况剔除空瓦片(L 形分布时省下大片空白)。""" + frame = _frame((0, 0, 2000, 1000)) + clusters = [_cluster((0, 0, 2000, 1000))] + assert len(tl.plan_regions(frame, clusters, s_line=10.0, view_px=(100, 50))) == 4 + + def keep_top(win): + return win["min_y"] >= 500 # 只保留上半(row 0) + + wins = tl.plan_regions(frame, clusters, s_line=10.0, view_px=(100, 50), + keep=keep_top) + assert len(wins) == 2 + assert all(w["min_y"] >= 500 for w in wins) + + +def test_plan_regions_degrades(): + frame = _frame((0, 0, 1000, 500)) + clusters = [_cluster((0, 0, 1000, 500))] + assert tl.plan_regions(frame, clusters, s_line=0.0, view_px=(100, 50)) == [] + assert tl.plan_regions(frame, clusters, s_line=10.0, view_px=(0, 0)) == [] + assert tl.plan_regions({"bbox": [0, 0, 0, 0]}, clusters, s_line=10.0, + view_px=(100, 50)) == [] + assert tl.plan_regions(frame, [], s_line=10.0, view_px=(100, 50)) == [] + + +def test_plan_regions_clamps_to_frame(): + frame = _frame((0, 0, 1000, 500)) + clusters = [_cluster((0, 0, 2000, 1000))] # 超出图框 + wins = tl.plan_regions(frame, clusters, s_line=10.0, view_px=(100, 50), max_tiles=4) + for w in wins: + assert w["min_x"] >= 0 and w["max_x"] <= 1000 + assert w["min_y"] >= 0 and w["max_y"] <= 500 diff --git a/verify_drawing_area.py b/verify_drawing_area.py new file mode 100644 index 0000000..35352b7 --- /dev/null +++ b/verify_drawing_area.py @@ -0,0 +1,140 @@ +"""绘图区最大化验证:`set_drawing_area(max)` 到底把绘图区放大了多少,`restore` 能否保真还原。 + +不消耗 VLM token(只调截图工具 + 解码 PNG 量尺寸)。 + +用法: + python verify_drawing_area.py [--wait 3.0] [--restore-wait 1.5] + +前置:CAD 在线(127.0.0.1:8080 监听)且已加载最新插件。不需要 DEEPSEEK_API_KEY。 + +判定: + 1. max 后绘图区面积应显著大于基线(实测约 3.7x); + 2. restore 后窗口矩形 / 最大化状态 / 绘图区尺寸应回到基线(容差 ±2px)。 + +注意:脚本会改动 CAD 窗口位置与干净屏幕状态,结束时一定还原(finally)。 +""" + +import argparse +import ctypes +import sys +import time +from ctypes import wintypes + +from dwg_review.llm_client import image_size_b64 +from dwg_review.mcp_client import McpClient, result_images, result_json + +_TOL = 2 # 还原容差(像素) + + +# -------------------------------------------------------------------------- +# Win32:定位 CAD 主窗口并读取其状态 +# -------------------------------------------------------------------------- +def _user32(): + u = ctypes.windll.user32 + u.GetWindowTextW.argtypes = [wintypes.HWND, wintypes.LPWSTR, ctypes.c_int] + u.IsWindowVisible.argtypes = [wintypes.HWND] + u.GetWindowRect.argtypes = [wintypes.HWND, ctypes.POINTER(wintypes.RECT)] + u.IsZoomed.argtypes = [wintypes.HWND] + return u + + +def find_main_hwnd(u): + """枚举可见顶层窗口,取标题含 AutoCAD 且带 [图纸名] 的那个。""" + found = [] + proc_type = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM) + + def cb(hwnd, _): + if not u.IsWindowVisible(hwnd): + return True + n = u.GetWindowTextLengthW(hwnd) + if n == 0: + return True + buf = ctypes.create_unicode_buffer(n + 1) + u.GetWindowTextW(hwnd, buf, n + 1) + if "AutoCAD" in buf.value and "[" in buf.value: + found.append(hwnd) + return False + return True + + u.EnumWindows(proc_type(cb), 0) + return found[0] if found else None + + +def win_state(u, hwnd): + rc = wintypes.RECT() + u.GetWindowRect(hwnd, ctypes.byref(rc)) + return (rc.left, rc.top, rc.right, rc.bottom), bool(u.IsZoomed(hwnd)) + + +# -------------------------------------------------------------------------- +# 测量 +# -------------------------------------------------------------------------- +def shot_size(client): + """截一张整图,返回 (宽, 高)。""" + resp = client.call_tool("get_viewport_screenshot", {}) + imgs = result_images(resp) + if not imgs: + return None + return image_size_b64(imgs[0][0]) + + +def _area(sz): + return (sz[0] * sz[1]) if sz else 0 + + +def main(argv=None): + ap = argparse.ArgumentParser() + ap.add_argument("--wait", type=float, default=3.0, help="max 后等待渲染秒数") + ap.add_argument("--restore-wait", type=float, default=1.5, + help="restore 后等待秒数(须 > 插件内 700ms 延迟还原)") + args = ap.parse_args(argv) + + u = _user32() + hwnd = find_main_hwnd(u) + if not hwnd: + print("找不到 AutoCAD 主窗口,请先启动 CAD", file=sys.stderr) + return 1 + print(f"CAD 主窗口: {hwnd:#x}") + + client = McpClient() + rc0, zoom0 = win_state(u, hwnd) + base = shot_size(client) + print(f"[基线] 窗口矩形={list(rc0)} 最大化={zoom0} 绘图区={base}") + + ok = True + try: + info = result_json(client.call_tool("set_drawing_area", {"mode": "max"}), {}) + time.sleep(args.wait) + big = shot_size(client) + rc1, zoom1 = win_state(u, hwnd) + ratio = _area(big) / _area(base) if _area(base) else 0.0 + print(f"[max] 窗口矩形={list(rc1)} 最大化={zoom1} 绘图区={big} " + f"面积={ratio:.2f}x 工具返回={info.get('main_rect')}") + if ratio < 1.5: + print(" !! 绘图区放大不足 1.5x,请检查窗口是否被其它窗口遮挡") + ok = False + finally: + client.call_tool("set_drawing_area", {"mode": "restore"}) + time.sleep(args.restore_wait) + + rc2, zoom2 = win_state(u, hwnd) + back = shot_size(client) + print(f"[restore] 窗口矩形={list(rc2)} 最大化={zoom2} 绘图区={back}") + + # ---- 还原保真度判定 ---- + if zoom2 != zoom0: + print(f" !! 最大化状态未还原: {zoom0} -> {zoom2}") + ok = False + if any(abs(a - b) > _TOL for a, b in zip(rc0, rc2)): + print(f" !! 窗口矩形未还原(容差 {_TOL}px)") + ok = False + if base and back and any(abs(a - b) > _TOL for a, b in zip(base, back)): + print(f" !! 绘图区尺寸未还原: {base} -> {back}") + ok = False + + print("\n结果:", "PASS" if ok else "FAIL") + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/verify_grounding.py b/verify_grounding.py new file mode 100644 index 0000000..ff8ebe1 --- /dev/null +++ b/verify_grounding.py @@ -0,0 +1,131 @@ +"""实测 deepseek-flash 的 grounding:能否给出可用的「放大区域」归一化坐标。 + +流程:截一张真实图框整体图 → 问 VLM「哪些区域需要放大」→ 解析它给的归一化坐标 +→ 映射成 CAD 世界坐标(WCS)→ 按坐标放大截图,供目视验证 grounding 是否可用。 + +用法: + python verify_grounding.py [--frames 1] [--out reports/_probe] + +前置:CAD 在线(127.0.0.1:8080 监听)、DEEPSEEK_API_KEY 可用。 +""" + +import argparse +import base64 +import json +import time +from pathlib import Path + +from dwg_review import config +from dwg_review.findings import extract_json_block +from dwg_review.llm_client import LlmClient, downsample_image_b64 +from dwg_review.mcp_client import result_images +from dwg_review.phases import planning +from dwg_review.context import ReviewContext + +_PROMPT = ( + "这是某工程图纸中一个图框的截图。请判断:图中哪些区域**细节不清、需要放大**才能看清?\n" + "用**归一化坐标**给出想放大的区域:x0,y0,x1,y1 取值 0~1," + "图片左上角为 (0,0)、右下角为 (1,1)。\n" + "最多给 3 个区域,按重要性排序。\n" + '只输出 JSON,不要解释:{"regions":[{"bbox":[x0,y0,x1,y1],"reason":"..."}]}' +) + + +def _save(b64, path): + path.write_bytes(base64.b64decode(b64)) + return path + + +def _norm_to_wcs(win, x0, y0, x1, y1): + """归一化坐标(左上原点)→ WCS window。""" + dx = win["max_x"] - win["min_x"] + dy = win["max_y"] - win["min_y"] + return { + "min_x": win["min_x"] + x0 * dx, + "max_x": win["min_x"] + x1 * dx, + "min_y": win["max_y"] - y1 * dy, # 图片 y 向下,WCS y 向上 + "max_y": win["max_y"] - y0 * dy, + } + + +def main(argv=None): + ap = argparse.ArgumentParser() + ap.add_argument("--frames", type=int, default=1) + ap.add_argument("--out", default="reports/_probe") + args = ap.parse_args(argv) + + ctx = ReviewContext.create() + plan = planning.plan(ctx) + frames = plan.get("frames", [])[: args.frames] + if not frames: + print("未识别到图框") + return 1 + + out_dir = Path(config.PROJECT_ROOT) / args.out + out_dir.mkdir(parents=True, exist_ok=True) + + frame = frames[0] + win = { + k: float(v) + for k, v in zip(("min_x", "min_y", "max_x", "max_y"), frame["bbox"]) + } + print("图框 window:", win) + + ctx.tools.call("zoom_to_window", dict(win, margin=0.02)) + time.sleep(config.RENDER_WAIT) + shot = ctx.tools.call("get_viewport_screenshot", {}) + imgs = result_images(shot) + if not imgs: + print("无截图") + return 1 + b64, mime = imgs[0] + nb, nm = downsample_image_b64(b64, mime) + print("整体图:", _save(b64, out_dir / "overall.png")) + + llm = LlmClient() + print("模型:", llm.model) + messages = [ + {"role": "system", "content": "你是资深工程图纸审图助手。"}, + { + "role": "user", + "content": [ + {"type": "text", "text": _PROMPT}, + LlmClient.image_content(nb, nm), + ], + }, + ] + resp = llm.chat(messages) + text = resp.choices[0].message.content or "" + print("=== VLM 原始响应 ===") + print(text) + + block = extract_json_block(text) + if not block: + print("[X] 未解析出 JSON") + return 1 + data = json.loads(block) + regions = data.get("regions") or [] + print(f"=== 解析到 {len(regions)} 个区域 ===") + + for i, r in enumerate(regions, 1): + bbox = r.get("bbox") + if not (isinstance(bbox, list) and len(bbox) == 4): + print(f" 区域 {i}: bbox 非法 {bbox}") + continue + x0, y0, x1, y1 = (float(v) for v in bbox) + sub = _norm_to_wcs(win, x0, y0, x1, y1) + print(f" 区域 {i}: bbox={bbox} reason={r.get('reason')!r}") + print(f" -> WCS {sub}") + ctx.tools.call("zoom_to_window", dict(sub, margin=0.05)) + time.sleep(config.RENDER_WAIT) + s2 = ctx.tools.call("get_viewport_screenshot", {}) + im2 = result_images(s2) + if im2: + print(" -> 已保存", _save(im2[0][0], out_dir / f"region_{i}.png")) + + print("\n截图目录:", out_dir) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/verify_heatmap.py b/verify_heatmap.py new file mode 100644 index 0000000..d573bf2 --- /dev/null +++ b/verify_heatmap.py @@ -0,0 +1,125 @@ +"""实体分布热力图验证:取图框内实体 bbox → 建占用网格 → 聚类 → 渲染 PNG 供人工目视。 + +不消耗 VLM token(纯几何统计 + 本地渲染)。 + +用法: + python verify_heatmap.py [--frames 3] [--div 32,64] [--dilate 0] [--out reports/_probe] + +前置:CAD 在线(127.0.0.1:8080 监听)。不需要 DEEPSEEK_API_KEY。 + +同时把每个图框的实体 bbox 缓存为 JSON,便于后续**离线**反复调聚类 / 切分参数。 +""" + +import argparse +import json +from pathlib import Path + +from dwg_review import config +from dwg_review import heatmap +from dwg_review.context import ReviewContext +from dwg_review.mcp_client import result_json +from dwg_review.phases import planning + +_PAGE = 1000 # query_entities 的 limit 上限 +_MAX_PAGES = 20 # 兜底,避免异常分页死循环 + + +def fetch_boxes(tools, window): + """分页取回 window 内全部实体的 bbox。 + + 返回 (boxes, total, fetched);total 为工具报告的总数,fetched 为实际取回条数。 + """ + boxes, offset, total = [], 0, 0 + for _ in range(_MAX_PAGES): + env = result_json( + tools.call("query_entities", {"window": window, "limit": _PAGE, "offset": offset}), + {}, + ) + items = env.get("items") or [] + boxes.extend(heatmap.entity_boxes(env)) + total = int(env.get("total") or len(items)) + offset += len(items) + if not items or offset >= total: + break + return boxes, total, offset + + +def main(argv=None): + ap = argparse.ArgumentParser() + ap.add_argument("--frames", type=int, default=3, help="最多分析的图框数(0=全部)") + ap.add_argument("--div", default="32", help="网格划分(长边切几份),逗号分隔可多档对比") + ap.add_argument("--dilate", type=int, default=0, help="聚类前膨胀格数(合并相近内容)") + ap.add_argument("--max-area-frac", type=float, default=0.5, + help="丢弃 bbox 面积超过图框面积该比例的实体(默认 0.5,去图框边框/图签块)") + ap.add_argument("--out", default="reports/_probe") + args = ap.parse_args(argv) + + divs = [int(x) for x in str(args.div).split(",") if x.strip()] + + ctx = ReviewContext.create(need_llm=False) + plan = planning.plan(ctx) + frames = plan.get("frames", []) + if args.frames > 0: + frames = frames[: args.frames] + if not frames: + print("未识别到图框") + return 1 + + out_dir = Path(config.PROJECT_ROOT) / args.out + out_dir.mkdir(parents=True, exist_ok=True) + + for i, frame in enumerate(frames, 1): + bbox = frame.get("bbox") + if not bbox or len(bbox) != 4: + print(f"[图框 {i}] 缺少有效 bbox,跳过") + continue + win = { + k: float(v) + for k, v in zip(("min_x", "min_y", "max_x", "max_y"), bbox) + } + print(f"\n[图框 {i}] sheet={frame.get('sheet_size')} scale={frame.get('sheet_scale')} " + f"bbox={bbox}") + + boxes, total, fetched = fetch_boxes(ctx.tools, win) + kept = heatmap.drop_oversized(boxes, win, args.max_area_frac) + dropped = len(boxes) - len(kept) + print(f" 实体:工具报告 {total} 条,取回 {fetched} 条,有效 bbox {len(boxes)} 个;" + f"丢弃超大实体(图框边框/图签块等){dropped} 个 -> 参与分析 {len(kept)} 个") + boxes = kept + + # 缓存 bbox,便于离线调参 + cache = out_dir / f"boxes_frame{i}.json" + cache.write_text( + json.dumps({"frame": frame, "window": win, "boxes": boxes}, + ensure_ascii=False), + encoding="utf-8", + ) + + for div in divs: + cell = heatmap.div_to_cell(win, div) + grid = heatmap.build_grid(win, boxes, cell) + clusters = heatmap.cluster_cells(grid, dilate=args.dilate) + st = heatmap.grid_stats(grid, clusters) + print(f" [div={div}] 格子 {st['cols']}x{st['rows']}(cell={cell:.1f})" + f" 占用格 {st['occupied']} ({st['occupied_ratio']:.0%})" + f" 最大格计数 {st['max_count']}") + print(f" 簇 {st['clusters']} 个,最大簇 {st['max_cluster_cells']} 格 / " + f"{st['max_cluster_count']} 个实体") + + png = out_dir / f"heatmap_frame{i}_div{div}.png" + heatmap.render_overlay(png, win, boxes, grid, clusters) + print(f" -> {png.name}") + + # 前几个簇的窗口(后续瓦片规划直接复用) + for j, cl in enumerate(clusters[:5], 1): + x1, y1, x2, y2 = cl["bbox"] + print(f" 簇{j}: {len(cl['cells'])} 格 / {cl['count']} 实体 " + f"bbox=({x1:.0f},{y1:.0f},{x2:.0f},{y2:.0f}) " + f"尺寸={x2 - x1:.0f}x{y2 - y1:.0f}") + + print(f"\n输出目录: {out_dir}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/verify_text_resolution.py b/verify_text_resolution.py new file mode 100644 index 0000000..b0aea62 --- /dev/null +++ b/verify_text_resolution.py @@ -0,0 +1,333 @@ +"""CAD 实测标定 VLM 读图下限(临时加字 + 平行线,测完自动清理、不保存图纸)。 + +与 `verify_vlm_resolution_offline.py` 配套:离线预扫给**区间**,本脚本在**真实 CAD +渲染**下确认最终 `R_text` / `R_line`(离线合成图偏干净,结论偏乐观)。 + +流程: + 1. 在空白区临时插入标定物(图层 `AI_CALIB`): + - 3 个单行文字,字高 T(默认 200 图形单位;1:100 时 = 纸面 2mm),内容为易错随机串; + - 若干「1 条 / 2 条」平行线对(世界间距按目标像素间距换算)。 + 2. 量出绘图区**原生像素** → 有效像素 eff(长边 ≤ `IMAGE_MAX_EDGE`)+ 视口宽高比。 + 3. 目标像素字高 target:窗口宽 = `T*eff_w/target`、高 = 宽/视口宽高比 → zoom(margin=0) + → 截图 → 下采样 → 问 VLM 读字。 + 4. 线对同理:世界间距 `L = g*W_REF/eff_w`,问「1 条还是 2 条」。 + 5. 汇总 R_text / R_line;无论成败都在 finally 里 `delete_entities` 清理。 + +用法: + python verify_text_resolution.py # 自动选空白锚点 + python verify_text_resolution.py --anchor 500000,0 + python verify_text_resolution.py --targets 6,8,10,12,16 --gaps 2,3,4,6,8 + python verify_text_resolution.py --keep # 保留标定物(调试) + +前置:CAD 在线(127.0.0.1:8080)+ `DEEPSEEK_API_KEY`。需要 Pillow。 +""" + +import argparse +import json +import random +import time +from pathlib import Path + +from dwg_review import config, tiling +from dwg_review.context import ReviewContext +from dwg_review.llm_client import LlmClient, downsample_image_b64, image_size_b64 +from dwg_review.mcp_client import result_images, result_json +from dwg_review.phases import planning + +# 复用离线脚本的随机串与解析工具(避免重复实现) +from verify_vlm_resolution_offline import _ALPHABET, _extract_json, _lenient, _norm_token + +CALIB_LAYER = "AI_CALIB" +_TEXT_GAP = 2000.0 # 文字之间的水平间距(世界单位) +_LINE_LEN_FRAC = 0.6 # 线长占测线窗口宽的比例 +_W_REF = 20000.0 # 测线用的固定窗口宽(世界单位) +_SINGLE_DX = 30000.0 # 「单条线」相对线对的水平偏移(避免同框) +_ROW_DY = 6000.0 # 各档线对的行距 +_FIRST_ROW_DY = 10000.0 # 第一行线对相对文字的下移量 + +_SYS_TEXT = "你是图像文字识别器。只输出一个 JSON 对象,不要任何解释、不要 markdown 代码块。" +_SYS_LINE = "你是工程图纸线型识别器。只输出一个 JSON 对象,不要任何解释、不要 markdown 代码块。" + + +# --------------------------------------------------------------------------- +# 基础交互 +# --------------------------------------------------------------------------- +def _shot(ctx): + imgs = result_images(ctx.tools.call("get_viewport_screenshot", {})) + return imgs[0] if imgs else None + + +def _zoom_and_shot(ctx, win): + ctx.tools.call("zoom_to_window", dict(win, margin=0.0)) + time.sleep(config.RENDER_WAIT) + return _shot(ctx) + + +def _ask(ctx, shot, system, prompt, out_png=None): + """截图(先下采样)→ 问 VLM → 返回解析后的 dict(失败 None)。""" + b64, mime = downsample_image_b64(shot[0], shot[1]) + if out_png is not None: + import base64 + + out_png.write_bytes(base64.b64decode(b64)) + resp = ctx.llm.chat([ + {"role": "system", "content": system}, + {"role": "user", "content": [ + {"type": "text", "text": prompt}, + LlmClient.image_content(b64, mime), + ]}, + ]) + return _extract_json(resp.choices[0].message.content) + + +def _center(win): + return ((win["min_x"] + win["max_x"]) / 2.0, (win["min_y"] + win["max_y"]) / 2.0) + + +def _window(cx, cy, w, h): + return {"min_x": cx - w / 2.0, "min_y": cy - h / 2.0, + "max_x": cx + w / 2.0, "max_y": cy + h / 2.0} + + +# --------------------------------------------------------------------------- +# 标定物 +# --------------------------------------------------------------------------- +def _auto_anchor(ctx): + """把锚点放在所有图框的右侧远处(空白区)。""" + plan = planning.plan(ctx) + boxes = [f.get("bbox") for f in (plan.get("frames") or []) + if isinstance(f.get("bbox"), list) and len(f["bbox"]) == 4] + if not boxes: + return (0.0, 0.0) + return (max(b[2] for b in boxes) + 50000.0, min(b[1] for b in boxes)) + + +def _calib_window(ax, ay, n_gaps): + """覆盖全部标定物的查询窗口。""" + return { + "min_x": ax - 2 * _TEXT_GAP, + "min_y": ay - _FIRST_ROW_DY - n_gaps * _ROW_DY - 2 * _W_REF, + "max_x": ax + _SINGLE_DX + 2 * _W_REF, + "max_y": ay + 2 * _TEXT_GAP, + } + + +def _add_calibration(ctx, ax, ay, gaps, text_h, eff_w, rng, color=7): + """插入标定物,返回 (文字 token 列表, 各档线对中心 y 列表)。 + + `color` 默认 7(白/黑自适应),保证**黑底白字**的高对比——标定的是**分辨率下限**, + 不能把「颜色对比度不足」混进来(实测红色 ACI 1 在 12px 时完全读不出)。 + """ + tokens = ["".join(rng.choice(_ALPHABET) for _ in range(6)) for _ in range(3)] + for i, tok in enumerate(tokens): + ctx.tools.call("add_text", { + "text": tok, "position": [ax + i * _TEXT_GAP, ay], + "height": text_h, "layer": CALIB_LAYER, "color": color, + }) + + line_len = _LINE_LEN_FRAC * _W_REF + ys = [] + for k, g in enumerate(gaps): + spacing = g * _W_REF / eff_w # 世界间距 ↔ 目标像素间距 + yk = ay - _FIRST_ROW_DY - k * _ROW_DY + ys.append(yk) + # 线对(2 条) + for dy in (-spacing / 2.0, spacing / 2.0): + ctx.tools.call("add_line", { + "start": [ax - line_len / 2.0, yk + dy], + "end": [ax + line_len / 2.0, yk + dy], + "layer": CALIB_LAYER, "color": color, + }) + # 对照(1 条) + sx = ax + _SINGLE_DX + ctx.tools.call("add_line", { + "start": [sx - line_len / 2.0, yk], + "end": [sx + line_len / 2.0, yk], + "layer": CALIB_LAYER, "color": color, + }) + return tokens, ys + + +def _collect_handles(ctx, window): + env = result_json( + ctx.tools.call("query_entities", { + "window": window, "layer_filter": CALIB_LAYER, "limit": 1000, + }), + {}, + ) + return [it.get("handle") for it in (env.get("items") or []) if it.get("handle")] + + +# --------------------------------------------------------------------------- +# 各子测试 +# --------------------------------------------------------------------------- +def _text_center(ctx, window, fallback): + """取文字类实体的 bbox 中心(拿不到则用 fallback)。 + + 注意:`query_entities` 的 `type_filter` **必须是列表**(传字符串会被静默忽略)。 + 这里同时按 `tiling.is_text_type` 在 Python 侧再过滤一次,双保险。 + """ + env = result_json( + ctx.tools.call("query_entities", { + "window": window, "layer_filter": CALIB_LAYER, + "type_filter": ["TEXT"], "limit": 1000, + }), + {}, + ) + boxes = [] + for it in env.get("items") or []: + if not tiling.is_text_type(it.get("type")): + continue + b = tiling.norm_box(it.get("bbox")) + if b: + boxes.append(b) + if not boxes: + return fallback + return ( + (min(b[0] for b in boxes) + max(b[2] for b in boxes)) / 2.0, + (min(b[1] for b in boxes) + max(b[3] for b in boxes)) / 2.0, + ) + + +def run_text_levels(ctx, tokens, center, targets, text_h, eff_w, aspect, out_dir, repeat): + rows = [] + for target in targets: + w = text_h * eff_w / float(target) + win = _window(center[0], center[1], w, w / aspect) + trials = [] + for _ in range(max(1, repeat)): + shot = _zoom_and_shot(ctx, win) + if not shot: + trials.append({"got": [], "exact": 0}) + continue + data = _ask( + ctx, shot, _SYS_TEXT, + "图中是若干处**白色字符**(黑底)。请把能看清的字符串按从左到右顺序读出," + '输出 JSON:{"texts":["串1","串2",...]}。看不清的串填空字符串。', + out_png=out_dir / f"text_target{target}.png", + ) + got = [str(x) for x in (data or {}).get("texts") or []] + exact = sum(1 for i, t in enumerate(tokens) + if i < len(got) and _norm_token(got[i]) == t) + trials.append({"got": got, "exact": exact}) + best = max(t["exact"] for t in trials) + row = {"target_px": target, "tokens": tokens, "trials": trials, + "n": len(tokens), "best_exact": best, "readable": best == len(tokens)} + rows.append(row) + print(f" 目标字高 {target:>3}px 命中 {best}/{len(tokens)} 可读={row['readable']}") + return rows + + +def run_line_levels(ctx, ax, ys, gaps, aspect, out_dir, repeat): + """对每档像素间距,分别看「2 条线对」与「1 条对照」是否被正确区分。""" + rows = [] + prompt = ('图中是**水平直线**(黑底白线)。只判断线条数量是 1 还是 2,' + '输出 JSON:{"count": 1 或 2}。') + for g, yk in zip(gaps, ys): + res = {} + for kind, cx in (("pair", ax), ("single", ax + _SINGLE_DX)): + got = None + for _ in range(max(1, repeat)): + shot = _zoom_and_shot(ctx, _window(cx, yk, _W_REF, _W_REF / aspect)) + if not shot: + continue + data = _ask(ctx, shot, _SYS_LINE, prompt, + out_png=out_dir / f"line_g{g}_{kind}.png") + try: + got = int(data.get("count")) + except (TypeError, ValueError, AttributeError): + got = None + if got == (2 if kind == "pair" else 1): + break + res[kind] = got + ok = res.get("pair") == 2 and res.get("single") == 1 + rows.append({"gap_px": g, "pair": res.get("pair"), "single": res.get("single"), + "resolved": ok}) + print(f" 目标间距 {g:>3}px 线对={res.get('pair')} 单条={res.get('single')} " + f"可分辨={ok}") + return rows + + +def _parse_floats(s): + return [float(x) for x in str(s).split(",") if x.strip()] + + +def main(argv=None): + ap = argparse.ArgumentParser() + ap.add_argument("--anchor", default="auto", help='"auto" 或 "x,y"') + ap.add_argument("--text-height", type=float, default=200.0, + help="标定字高(图形单位;1:100 时 200 = 纸面 2mm)") + ap.add_argument("--targets", default="6,8,10,12,16,20", help="目标像素字高扫描档") + ap.add_argument("--gaps", default="2,3,4,5,6,8,12", help="目标像素线间距扫描档") + ap.add_argument("--repeat", type=int, default=1, help="每档重复次数") + ap.add_argument("--seed", type=int, default=7, help="随机串种子") + ap.add_argument("--keep", action="store_true", help="保留标定物(调试用)") + ap.add_argument("--out", default="reports/_probe/vlm_calib_cad") + args = ap.parse_args(argv) + + targets = [int(x) for x in _parse_floats(args.targets)] + gaps = [int(x) for x in _parse_floats(args.gaps)] + + ctx = ReviewContext.create(need_llm=True) + out_dir = Path(config.PROJECT_ROOT) / args.out + out_dir.mkdir(parents=True, exist_ok=True) + + if args.anchor.strip().lower() == "auto": + ax, ay = _auto_anchor(ctx) + else: + ax, ay = (float(v) for v in args.anchor.split(",")) + print(f"锚点:({ax:.0f}, {ay:.0f}) 模型:{ctx.llm.model}") + + # 量视口像素(决定世界↔像素换算) + base = _shot(ctx) + if not base: + raise SystemExit("无法截图:get_viewport_screenshot 无返回") + vp = image_size_b64(base[0]) + if not vp: + raise SystemExit("无法解析截图尺寸(缺 Pillow 或图片非法)") + eff = tiling.effective_px(vp, config.IMAGE_MAX_EDGE) + aspect = vp[0] / float(vp[1]) + print(f"绘图区原生 {vp[0]}x{vp[1]} → 有效 {eff[0]}x{eff[1]} 视口宽高比 {aspect:.3f}") + + handles = [] + results = {"viewport": list(vp), "effective": list(eff), + "text_height": args.text_height, "anchor": [ax, ay]} + try: + rng = random.Random(args.seed) + tokens, ys = _add_calibration(ctx, ax, ay, gaps, args.text_height, eff[0], rng) + win = _calib_window(ax, ay, len(gaps)) + handles = _collect_handles(ctx, win) + print(f"已插入标定物:{len(handles)} 个实体(图层 {CALIB_LAYER})") + + center = _text_center(ctx, win, (ax + _TEXT_GAP, ay)) + + print("\n-- 文字可读性(真实 CAD 渲染) --") + text_rows = run_text_levels(ctx, tokens, center, targets, args.text_height, + eff[0], aspect, out_dir, args.repeat) + + print("\n-- 线间距可分辨性(1 vs 2) --") + line_rows = run_line_levels(ctx, ax, ys, gaps, aspect, out_dir, args.repeat) + + r_text = next((r["target_px"] for r in text_rows if r["readable"]), None) + r_line = next((r["gap_px"] for r in line_rows if r["resolved"]), None) + results.update({"text": text_rows, "line": line_rows, + "R_text": r_text, "R_line": r_line}) + print(f"\n实测:R_text={r_text}px R_line={r_line}px") + print("回填:dwg_review/config.py 的 TEXT_READABLE_PX / LINE_READABLE_PX") + finally: + if handles and not args.keep: + out = result_json(ctx.tools.call("delete_entities", {"handles": handles}), {}) + print(f"\n清理标定物:删除 {out.get('count')} 个,失败 {len(out.get('failed') or [])} 个") + elif handles: + print(f"\n--keep:保留 {len(handles)} 个标定物(图层 {CALIB_LAYER})") + ctx.tools.call("zoom_extents", {}) + + (out_dir / "summary.json").write_text( + json.dumps(results, ensure_ascii=False, indent=2), encoding="utf-8" + ) + print(f"输出目录:{out_dir}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/verify_vlm_resolution_offline.py b/verify_vlm_resolution_offline.py new file mode 100644 index 0000000..cba4549 --- /dev/null +++ b/verify_vlm_resolution_offline.py @@ -0,0 +1,415 @@ +"""离线标定 VLM 读图下限(不碰 CAD,纯 PIL 合成)。 + +目的:在接入自适应瓦片规划前,先离线测出视觉模型(deepseek-flash)的 +「最小可读文字像素高 `R_text`」与「最小可分辨线间距 `R_line`」,为 `tiling.py` +的 `world_per_px` 提供**区间参考**。 + +做法:PIL 合成"类图纸"图(可控背景极性),**精确控制像素字高**(先用 +`font.getbbox` 二分出目标 ink 高度对应的字号),加可控间距/线宽的平行线, +以及实线 / 虚线对比。每档发 VLM,提示词强约束 JSON,与真值比对。 + +重要前提:**合成图比真实 CAD 渲染干净 → 结论偏乐观**,只作区间;最终以 +`verify_text_resolution.py`(CAD 临时加字实测)为准。 + +用法: + python verify_vlm_resolution_offline.py --dry-run # 只出图,不调 VLM + python verify_vlm_resolution_offline.py --repeat 2 # 每档重复 2 次 + python verify_vlm_resolution_offline.py --heights 4,6,8,10,12 --spacings 2,3,4,6,8 + python verify_vlm_resolution_offline.py --polarity light # 白底黑线 + +前置:需要 Pillow;调 VLM 需要 DEEPSEEK_API_KEY(--dry-run 不需要密钥)。 +""" + +import argparse +import base64 +import io +import json +import math +import random +import re +from pathlib import Path + +from dwg_review import config +from dwg_review.llm_client import HAS_PIL, LlmClient, downsample_image_b64 + +# 画布宽固定为 1280(= IMAGE_MAX_EDGE),使发给 VLM 前**不发生下采样**, +# 与真实管线「截图长边缩到 1280」后的条件一致。 +_CANVAS_W = 1280 +_MIN_CANVAS_H = 280 + +# 易混字符集:去掉 I / O,避免"真值本身歧义"污染标定 +_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" + +_DARK = {"bg": (0, 0, 0), "fg": (255, 255, 255)} +_LIGHT = {"bg": (255, 255, 255), "fg": (0, 0, 0)} +_POLARITY = {"dark": _DARK, "light": _LIGHT} + +_FONT_CANDIDATES = ( + r"C:\Windows\Fonts\consola.ttf", # 等宽,字高最稳 + r"C:\Windows\Fonts\cour.ttf", + r"C:\Windows\Fonts\lucon.ttf", + r"C:\Windows\Fonts\arial.ttf", + r"/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", + r"/System/Library/Fonts/Menlo.ttc", +) + +_PROBE = "K7Q2M9" # 全为大写/数字,ink 高度一致,用于二分字号 + +_SYS_TEXT = ( + "你是图像文字识别器。只输出一个 JSON 对象,不要任何解释、不要 markdown 代码块。" +) +_SYS_LINE = ( + "你是工程图纸线型识别器。只输出一个 JSON 对象,不要任何解释、不要 markdown 代码块。" +) + + +# --------------------------------------------------------------------------- +# 字体与像素字高 +# --------------------------------------------------------------------------- +def find_font_path(explicit=None): + if explicit: + p = Path(explicit) + if p.exists(): + return str(p) + raise SystemExit(f"指定的字体不存在:{explicit}") + for cand in _FONT_CANDIDATES: + if Path(cand).exists(): + return cand + raise SystemExit("未找到可用 TTF 字体,请用 --font 指定") + + +def font_for_ink_height(target_h, font_path, probe=_PROBE): + """二分出使 `probe` 的 ink 高度最接近 target_h 的字体。 + + 返回 (font, size, actual_h)。ink 高度 = getbbox 的 (bottom - top)。 + """ + from PIL import ImageFont + + target_h = max(1, int(target_h)) + lo, hi = 1, 400 + best = None # (font, size, h) + while lo <= hi: + mid = (lo + hi) // 2 + font = ImageFont.truetype(font_path, mid) + bb = font.getbbox(probe) + h = bb[3] - bb[1] + if best is None or abs(h - target_h) < abs(best[2] - target_h): + best = (font, mid, h) + if h == target_h: + break + if h < target_h: + lo = mid + 1 + else: + hi = mid - 1 + return best + + +# --------------------------------------------------------------------------- +# 合成图 +# --------------------------------------------------------------------------- +def _canvas(h, polarity): + from PIL import Image + + pal = _POLARITY[polarity] + return Image.new("RGB", (_CANVAS_W, max(_MIN_CANVAS_H, int(h))), pal["bg"]) + + +def synth_text_image(height_px, tokens, font_path, *, polarity="dark", left=90): + """合成 N 行等高文字(每行一个随机串)。返回 (image, actual_h)。""" + from PIL import ImageDraw + + font, _size, actual_h = font_for_ink_height(height_px, font_path) + n = len(tokens) + pitch = max(actual_h + 3, int(round(actual_h * 3))) + block_h = pitch * (n - 1) + actual_h + canvas_h = block_h + 4 * actual_h + 60 + img = _canvas(canvas_h, polarity) + draw = ImageDraw.Draw(img) + fg = _POLARITY[polarity]["fg"] + + y_top = (img.height - block_h) // 2 + for i, tok in enumerate(tokens): + bb = font.getbbox(tok) + # getbbox 的 top 可能为负(字体 ascent 内留白),补偿使 ink 顶对齐 y_top + draw.text((left, y_top - bb[1]), tok, font=font, fill=fg) + y_top += pitch + return img, actual_h + + +def synth_lines_image(spacing_px, count, *, width=1, polarity="dark", + x0=200, x1=1080, dashed=False, dash=(6, 4)): + """合成 count 条水平平行线,间距 spacing_px,线宽 width。返回 image。""" + from PIL import ImageDraw + + spacing_px = max(1, int(spacing_px)) + block_h = (count - 1) * spacing_px + canvas_h = max(block_h + 8 * max(spacing_px, 4), 2 * block_h + 60) + img = _canvas(canvas_h, polarity) + draw = ImageDraw.Draw(img) + fg = _POLARITY[polarity]["fg"] + + y0 = (img.height - block_h) // 2 + for i in range(count): + y = y0 + i * spacing_px + if dashed: + _draw_dashed(draw, x0, x1, y, dash[0], dash[1], fg, width) + else: + draw.line([(x0, y), (x1, y)], fill=fg, width=width) + return img + + +def _draw_dashed(draw, x0, x1, y, on, off, color, width): + x = x0 + while x < x1: + draw.line([(x, y), (min(x + on, x1), y)], fill=color, width=width) + x += on + off + + +def _to_b64(img, max_edge=None): + buf = io.BytesIO() + img.save(buf, format="PNG") + b64 = base64.b64encode(buf.getvalue()).decode("ascii") + return downsample_image_b64(b64, "image/png", max_edge=max_edge) + + +# --------------------------------------------------------------------------- +# VLM 交互 +# --------------------------------------------------------------------------- +def _extract_json(text): + """从模型输出里抠出第一个 JSON 对象(容忍 ```json 包裹与前后噪声)。""" + if not text: + return None + m = re.search(r"\{.*\}", text, re.S) + if not m: + return None + try: + return json.loads(m.group(0)) + except json.JSONDecodeError: + return None + + +def ask_vlm(client, img, system, prompt, *, max_edge=None): + """发一张图问 VLM,返回解析后的 dict(失败返回 None)。""" + b64, mime = _to_b64(img, max_edge) + resp = client.chat([ + {"role": "system", "content": system}, + {"role": "user", "content": [ + {"type": "text", "text": prompt}, + LlmClient.image_content(b64, mime), + ]}, + ]) + return _extract_json(resp.choices[0].message.content) + + +def _norm_token(s): + return re.sub(r"[^A-Z0-9]", "", str(s).upper()) + + +def _lenient(s): + """宽松归一:常见混淆 0<->O、1<->I<->L。""" + s = _norm_token(s) + return s.replace("0", "O").replace("1", "I").replace("L", "I") + + +# --------------------------------------------------------------------------- +# 各子测试 +# --------------------------------------------------------------------------- +def run_text_level(client, height_px, font_path, polarity, repeat, out_dir, dry_run): + rng = random.Random(1000 + height_px) + tokens = ["".join(rng.choice(_ALPHABET) for _ in range(6)) for _ in range(5)] + img, actual_h = synth_text_image(height_px, tokens, font_path, polarity=polarity) + png = out_dir / f"text_h{height_px}_{polarity}.png" + img.save(str(png)) + + prompt = ( + "图中是若干行**等高的白色字符**(黑底)。请逐行读出,按从上到下顺序输出 JSON:" + '{"texts":["第一行","第二行",...]}。无法辨认的行填空字符串。' + ) + if polarity == "light": + prompt = prompt.replace("白色字符**(黑底)", "黑色字符**(白底)") + + trials = [] + if not dry_run: + for _ in range(max(1, repeat)): + data = ask_vlm(client, img, _SYS_TEXT, prompt) + got = list(data.get("texts") or []) if isinstance(data, dict) else [] + got = [str(x) for x in got] + exact = sum(1 for i, t in enumerate(tokens) if i < len(got) and _norm_token(got[i]) == t) + lenient = sum(1 for i, t in enumerate(tokens) if i < len(got) and _lenient(got[i]) == _lenient(t)) + trials.append({"got": got, "exact": exact, "lenient": lenient}) + + n = len(tokens) + best_exact = max((t["exact"] for t in trials), default=0) + return { + "height_px": height_px, "actual_h": actual_h, "tokens": tokens, + "trials": trials, "n": n, "best_exact": best_exact, + "readable": best_exact == n, # 全部行都对 → 可读 + "mostly": best_exact >= n - 1, # 容错 1 行 + "png": png.name, + } + + +def _ask_count(client, img, prompt): + data = ask_vlm(client, img, _SYS_LINE, prompt) + if not isinstance(data, dict): + return None + try: + return int(data.get("count")) + except (TypeError, ValueError): + return None + + +def run_line_level(client, spacing, polarity, repeat, out_dir, dry_run): + """1 vs 2 **二选一**(比计数稳健得多):间距 spacing 的两条细线 vs 一条等宽粗线。 + + 实测该模型**无法可靠计数**(6 条线在 2~10px 间距下答 4/10/4/5/4/4), + 故线条判据只测「相邻线能否被分辨开」这一最核心的细节能力。 + """ + pair = synth_lines_image(spacing, 2, width=1, polarity=polarity) + single = synth_lines_image(20, 1, width=max(1, int(spacing)), polarity=polarity) + pair_png = out_dir / f"pair_s{spacing}_{polarity}.png" + single_png = out_dir / f"single_s{spacing}_{polarity}.png" + pair.save(str(pair_png)) + single.save(str(single_png)) + + prompt = ( + "图中是**水平直线**(黑底白线)。只判断线条数量是 1 还是 2," + '输出 JSON:{"count": 1 或 2}。' + ) + if polarity == "light": + prompt = prompt.replace("黑底白线", "白底黑线") + + trials = [] + if not dry_run: + for _ in range(max(1, repeat)): + g_pair = _ask_count(client, pair, prompt) + g_single = _ask_count(client, single, prompt) + trials.append({"pair": g_pair, "single": g_single, + "ok": g_pair == 2 and g_single == 1}) + return { + "spacing": spacing, "truth_pair": 2, "truth_single": 1, + "trials": trials, "resolved": any(t["ok"] for t in trials), + "png_pair": pair_png.name, "png_single": single_png.name, + } + + +def run_dashed_test(client, polarity, repeat, out_dir, dry_run): + """虚线 vs 实线识别(两条线,避开计数干扰)。""" + out = [] + for dashed in (False, True): + img = synth_lines_image(12, 2, width=1, polarity=polarity, + dashed=dashed, dash=(8, 6)) + png = out_dir / f"dashed_{'on' if dashed else 'off'}_{polarity}.png" + img.save(str(png)) + prompt = ( + "图中是两条**水平直线**(黑底白线)。判断它们是否为虚线," + '输出 JSON:{"dashed": true 或 false}。' + ) + if polarity == "light": + prompt = prompt.replace("黑底白线", "白底黑线") + trials = [] + if not dry_run: + for _ in range(max(1, repeat)): + data = ask_vlm(client, img, _SYS_LINE, prompt) + got = data.get("dashed") if isinstance(data, dict) else None + if isinstance(got, str): + got = got.strip().lower() in ("true", "1", "yes", "是") + trials.append({"got_dashed": got, "ok": got is dashed}) + out.append({"truth_dashed": dashed, "trials": trials, + "correct": any(t["ok"] for t in trials), "png": png.name}) + return out + + +# --------------------------------------------------------------------------- +# 主流程 +# --------------------------------------------------------------------------- +def _parse_ints(s): + return [int(x) for x in str(s).split(",") if x.strip()] + + +def main(argv=None): + ap = argparse.ArgumentParser() + ap.add_argument("--heights", default="4,6,8,10,12,16,20,24", help="文字像素高扫描档") + ap.add_argument("--spacings", default="2,3,4,5,6,8,10", help="线间距(px)扫描档") + ap.add_argument("--polarity", choices=("dark", "light", "both"), default="dark", + help="背景极性:dark=黑底白线(CAD 模型空间默认),light=白底黑线") + ap.add_argument("--repeat", type=int, default=1, help="每档重复次数(取多数/任一成功)") + ap.add_argument("--font", default=None, help="TTF 字体路径(默认自动查找等宽字体)") + ap.add_argument("--max-edge", type=int, default=None, help="发给 VLM 前长边上限(默认 config)") + ap.add_argument("--dry-run", action="store_true", help="只合成图并落盘,不调用 VLM") + ap.add_argument("--out", default="reports/_probe/vlm_calib") + args = ap.parse_args(argv) + + if not HAS_PIL: + raise SystemExit("离线标定需要 Pillow:pip install pillow") + + font_path = find_font_path(args.font) + out_dir = Path(config.PROJECT_ROOT) / args.out + out_dir.mkdir(parents=True, exist_ok=True) + + polarities = ("dark", "light") if args.polarity == "both" else (args.polarity,) + client = None + if not args.dry_run: + client = LlmClient() + print(f"模型:{client.model} 字体:{font_path}") + + heights = _parse_ints(args.heights) + spacings = _parse_ints(args.spacings) + results = {"polarity": list(polarities), "text": {}, "line": {}, "dashed": {}} + + for pol in polarities: + print(f"\n===== 极性 {pol} =====") + + print("-- 文字可读性 --") + text_rows = [] + for h in heights: + row = run_text_level(client, h, font_path, pol, args.repeat, out_dir, args.dry_run) + text_rows.append(row) + if args.dry_run: + print(f" 字高 {h:>3}px (实际 {row['actual_h']}px) -> {row['png']}") + else: + print(f" 字高 {h:>3}px (实际 {row['actual_h']}px) 命中 " + f"{row['best_exact']}/{row['n']} 可读={row['readable']}") + results["text"][pol] = text_rows + + print("-- 线间距可分辨性(1 vs 2 二选一) --") + line_rows = [] + for s in spacings: + row = run_line_level(client, s, pol, args.repeat, out_dir, args.dry_run) + line_rows.append(row) + if args.dry_run: + print(f" 间距 {s:>3}px -> {row['png_pair']} / {row['png_single']}") + else: + print(f" 间距 {s:>3}px 可分辨={row['resolved']} {row['trials']}") + results["line"][pol] = line_rows + + print("-- 虚线识别 --") + d = run_dashed_test(client, pol, args.repeat, out_dir, args.dry_run) + results["dashed"][pol] = d + if not args.dry_run: + for item in d: + print(f" 虚线={item['truth_dashed']} 识别正确={item['correct']}") + + # 汇总(取两极性中更优者作为"区间参考") + summary = {} + for pol in polarities: + text_rows = results["text"].get(pol, []) + line_rows = results["line"].get(pol, []) + r_text = next((r["height_px"] for r in text_rows if r["readable"]), None) + r_text_mostly = next((r["height_px"] for r in text_rows if r["mostly"]), None) + r_line = next((r["spacing"] for r in line_rows if r["resolved"]), None) + summary[pol] = {"R_text": r_text, "R_text_mostly": r_text_mostly, "R_line": r_line} + print(f"\n[{pol}] R_text(全部行正确)={r_text} R_text(容错1行)={r_text_mostly} " + f"R_line={r_line}") + + results["summary"] = summary + (out_dir / "summary.json").write_text( + json.dumps(results, ensure_ascii=False, indent=2), encoding="utf-8" + ) + print(f"\n输出目录: {out_dir}") + print("注意:合成图偏干净,结论偏乐观,仅作区间;以 CAD 实测 (verify_text_resolution.py) 为准。") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())