diff --git a/ai.memory/project_agent_roadmap.md b/ai.memory/project_agent_roadmap.md index 763cd31..d8ede57 100644 --- a/ai.memory/project_agent_roadmap.md +++ b/ai.memory/project_agent_roadmap.md @@ -1,6 +1,6 @@ --- name: Agent 路线与多源架构(2026-10-15) -description: dwg_review agent 的总目标、多源图纸分层架构(视觉为最大公约数)、工具层与 agent 层的正交分工、第一步多源证据层、截图去 UI、grounding 实测与后续路线 +description: dwg_review agent 的总目标、多源图纸分层架构(视觉为最大公约数)、工具层与 agent 层的正交分工、多源证据层、截图去 UI、grounding 实测、热力图 + 自适应瓦片规划 + VLM 分辨率标定与后续路线 type: project --- @@ -82,3 +82,50 @@ Agent 推理层(审查逻辑 / 清单 / 规则) (`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=...)` 按 `heatmap.occupied_boxes` 剔除**空瓦片**(实测有效)。 + +**已知局限**:本图纸的文字在**块 / 自定义实体**内,`get_text_content` 检不到 → 该簇会按 +**线条判据**出图(6px),块内文字可能读不清。要更保险可把 `LINE_READABLE_PX` 调到 8 +(等价于全用文字判据,代价是同样预算下覆盖面积减半)。 diff --git a/ai.memory/reference_environment.md b/ai.memory/reference_environment.md index d2d4b8a..38c5b96 100644 --- a/ai.memory/reference_environment.md +++ b/ai.memory/reference_environment.md @@ -43,6 +43,9 @@ 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` 保留 | > 历史:根目录 `test.py` / `test_autoagent.py` / `countOfCircle.py` / `agent_loop.py` / > `dwg_review_agent.py` 五个遗留调试脚本已于 2026-10-14 删除(前三个含硬编码 API Key, @@ -62,6 +65,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` 已从「某仓库的子目录」**独立为单独仓库**。 diff --git a/dwg_review/config.py b/dwg_review/config.py index 6e69023..5eb8c91 100644 --- a/dwg_review/config.py +++ b/dwg_review/config.py @@ -124,8 +124,9 @@ 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")) -# 每图框图片硬上限(整体 + 局部总张数) -EVIDENCE_MAX_IMAGES = int(os.environ.get("EVIDENCE_MAX_IMAGES", "5")) +# 每图框图片硬上限(整体 + 局部总张数)。 +# 默认 12 才能装下规划器给出的瓦片(实测测试图 A0 需 12 张);调小会截断规划结果。 +EVIDENCE_MAX_IMAGES = int(os.environ.get("EVIDENCE_MAX_IMAGES", "12")) # --------------------------------------------------------------------------- # 自适应瓦片规划(按实体分布决定截几张、截哪里) @@ -134,12 +135,14 @@ EVIDENCE_MAX_IMAGES = int(os.environ.get("EVIDENCE_MAX_IMAGES", "5")) TILING_ENABLED = _read_bool("TILING_ENABLED", True) # 纸面字高(mm)。国标标注字高 2~3mm,取保守下限 TEXT_HEIGHT_MM = float(os.environ.get("TEXT_HEIGHT_MM", "2.0")) -# VLM 可读的**最小像素字高**。 -# 离线标定(verify_vlm_resolution_offline.py,repeat=2):黑底 12px / 白底 10px 全行正确。 -# CAD 模型空间默认黑底 → 取 12;离线合成图偏干净,故以 B2 CAD 实测为最终依据。 -TEXT_READABLE_PX = int(os.environ.get("TEXT_READABLE_PX", "12")) -# VLM 可分辨的**最小线间距像素**。 -# 同上标定:4~5px 有噪声、≥6px 稳定(1 条 vs 2 条二选一)。取 6 保守。 +# 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")) diff --git a/dwg_review/heatmap.py b/dwg_review/heatmap.py index 40d9b6f..083bb69 100644 --- a/dwg_review/heatmap.py +++ b/dwg_review/heatmap.py @@ -246,6 +246,15 @@ def _cell_box(grid, r, c): return (x1, y_top - ch, x1 + cw, y_top) +def occupied_boxes(grid, min_count=1): + """占用格的**世界 bbox** 列表。 + + 供瓦片规划做「内容过滤」:簇 bbox 是**包围盒**,对 L 形/斜向分布会含大片空白, + 故瓦片切分后还要按这些格子判断该瓦片是否真有内容。 + """ + return [_cell_box(grid, r, c) for r, c in occupied_cells(grid, min_count)] + + def grid_to_windows(clusters, margin=0.0): """把簇 bbox 转成 zoom 窗口列表(保持输入顺序,即实体计数降序)。""" out = [] diff --git a/dwg_review/phases/evidence.py b/dwg_review/phases/evidence.py index ec3020b..ee3e5b3 100644 --- a/dwg_review/phases/evidence.py +++ b/dwg_review/phases/evidence.py @@ -127,10 +127,19 @@ def _plan_tiles(window, frame, boxes, text_boxes, truncated, view_px): return [] flags = tiling.cluster_text_flags(clusters, text_boxes) text_idx = {i for i, is_text in enumerate(flags) if is_text} + + # 内容过滤:簇 bbox 是包围盒,L 形/斜向分布会含大片空白 → 按**占用格**剔除空瓦片 + occ = heatmap.occupied_boxes(grid) + + 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 occ) + 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=_has_content, ) diff --git a/dwg_review/tiling.py b/dwg_review/tiling.py index 011b9b4..07e2c16 100644 --- a/dwg_review/tiling.py +++ b/dwg_review/tiling.py @@ -236,7 +236,7 @@ def subdivide(box, cols, rows, overlap=0.0, frame_win=None): 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): + view_px=(0, 0), max_tiles=36, overlap=0.0, keep=None): """把图框内的簇规划成一组 zoom 窗口(自适应瓦片)。 参数: @@ -246,7 +246,9 @@ def plan_regions(frame, clusters, *, text_clusters=(), s_text=0.0, s_line=0.0, - `s_text`/`s_line`:CAD 图形单位 / 像素(`world_per_px` 的输出); - `view_px` :单张截图的**有效像素** (ew, eh)(`effective_px` 的输出); - `max_tiles` :返回窗口数硬上界(文字簇优先保留); - - `overlap` :相邻瓦片重叠比例。 + - `overlap` :相邻瓦片重叠比例; + - `keep` :可选谓词 `f(window) -> bool`,返回 False 的瓦片被丢弃。 + 用于按**占用格**剔除簇 bbox 内的空白瓦片(簇 bbox 对 L 形/斜向分布含大片空白)。 返回行优先的 window 列表;**无法规划时返回 []**(调用方退回固定策略)。 """ @@ -278,6 +280,8 @@ def plan_regions(frame, clusters, *, text_clusters=(), s_text=0.0, s_line=0.0, 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 diff --git a/tests/test_heatmap.py b/tests/test_heatmap.py index eeaf7a6..fd9a9a2 100644 --- a/tests/test_heatmap.py +++ b/tests/test_heatmap.py @@ -171,3 +171,12 @@ def test_grid_stats_without_clusters(): st = hm.grid_stats(_grid([])) assert st["occupied"] == 0 assert "clusters" not in st + + +def test_occupied_boxes_returns_cell_world_boxes(): + # 左下角格(row 1 / col 0)→ 世界 (0,0)-(50,50) + assert hm.occupied_boxes(_grid([(0, 0, 50, 50)])) == [(0.0, 0.0, 50.0, 50.0)] + + +def test_occupied_boxes_empty(): + assert hm.occupied_boxes(_grid([])) == [] diff --git a/tests/test_tiling.py b/tests/test_tiling.py index c24d9fb..d7b6b22 100644 --- a/tests/test_tiling.py +++ b/tests/test_tiling.py @@ -189,6 +189,21 @@ def test_plan_regions_text_cluster_prioritized_on_truncation(): 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))] 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())