Files
cad-agent/dwg_review/tiling.py
T
gjm b757892c67 feat(review): agent 侧虚拟工具 inspect_region —— 按可读分辨率放大指定区域
解决三个缺口:① 放大没复用标定/瓦片数学(LLM 一次 zoom 只出一张,宽区域仍读不清);
②「看清没」由 VLM 自评(易过度自信/幻觉);③ 只能按 handle / 手算 bbox 指认,
图上看到但没 handle 的东西指认不了。

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

新增 dwg_review/inspect.py(agent 侧,不编 DLL):
- TOOL_SCHEMA:inspect_region(target, purpose);target 三形态 {handle} / {window} /
  {label,cx,cy,span},purpose = text(8px) / line(6px)。
- InspectState:单图框内「截图标签 → 世界可见范围」登记表,支持逐级下钻
  (返回的 inspect-N 可继续作为 label 指认)。
- resolve_target / _point_box:label+cx+cy → WCS。cy 是图像坐标(上→下),
  世界 y 必须翻转(y = y2 - cy*H)——单测专门钉住。
- plan_tiles:复用 world_per_px + tiles_for_box + subdivide;超 INSPECT_MAX_TILES(4)
  则硬失败并给出「缩小 target / 分多次请求」的指引(不静默降级);拿不到比例/像素
  则退回单张并如实告知。

关键几何:CAD 的 _.ZOOM _W 是等比适配(不拉伸),故「截图上的归一化坐标」必须按
max(W/ew, H/eh) 反算真实可见范围(新增 tiling.visible_box)——直接用送进去的窗口会偏。
zoom_to_window 返回的 view 是「加过 margin 的窗口」,不是可见范围,不能混用。

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

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

端到端实测(真实图纸 1 图框):取证与审查两阶段均在最大化态、结束精确还原(4 项 PASS);
LLM 的 11 次取证调用里 2 次是 inspect_region,且都显式给 window + purpose:"text"
(x[20000,115000] y[-6500,3000],即 A0 图框底部横条=标题栏所在)。
验收口径达成:报告报出标题栏乱码 ???????????????,而 get_text_content 对全图返回 0 条文字
(本图文字全在块/自定义实体内)→ 块内文字是被视觉放大读出来的。
pytest 343 passed。
2026-10-16 19:15:32 +08:00

349 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""自适应瓦片规划器:按实体分布把图框切成若干 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 box_to_window(box):
"""bbox 元组 → window 对象(`zoom_to_window` 的入参);非法返回 None。"""
b = norm_box(box)
if b is None:
return None
return {"min_x": b[0], "min_y": b[1], "max_x": b[2], "max_y": b[3]}
def window_to_box(win):
"""window 对象 → bbox 元组;非法返回 None。"""
if not isinstance(win, dict):
return None
return norm_box(
[win.get("min_x"), win.get("min_y"), win.get("max_x"), win.get("max_y")]
)
def expand_box(box, frac):
"""把 bbox 四周按 `frac` 比例外扩;非法输入返回 None。"""
b = norm_box(box)
if b is None:
return None
try:
f = float(frac or 0.0)
except (TypeError, ValueError):
f = 0.0
mx, my = (b[2] - b[0]) * f, (b[3] - b[1]) * f
return (b[0] - mx, b[1] - my, b[2] + mx, b[3] + my)
def visible_box(box, view_px, margin=0.0):
"""`zoom_to_window(box, margin)` 之后**实际可见**的世界范围。
CAD 的 `_.ZOOM _W` 是**等比适配**(不拉伸):指定窗口按视口宽高比放大到刚好装下,
多余的一维露出更多内容。故可见范围 = 以窗口中心为中心、按 `max(W/ew, H/eh)`
放大后的矩形。`view_px` 为截图的**原始像素**(未下采样);拿不到时退回
「窗口外扩 margin」的近似值。
用途:审查阶段把「截图上的归一化坐标」准确映射回 WCS(逐级下钻)。
"""
b = expand_box(box, margin)
if b is None:
return None
if not view_px or len(view_px) != 2:
return b
try:
ew, eh = int(view_px[0]), int(view_px[1])
except (TypeError, ValueError):
return b
if ew <= 0 or eh <= 0:
return b
w, h = b[2] - b[0], b[3] - b[1]
if w <= 0 or h <= 0:
return b
k = max(w / ew, h / eh)
vw, vh = ew * k, eh * k
cx, cy = (b[0] + b[2]) / 2.0, (b[1] + b[3]) / 2.0
return (cx - vw / 2.0, cy - vh / 2.0, cx + vw / 2.0, cy + vh / 2.0)
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]