feat(review): 截屏前最大化绘图区 + 空瓦片过滤覆盖回退路径

新增 MCP 工具 set_drawing_area(mode="max"|"restore"):最大化主窗口 + CLEANSCREENON
(隐藏功能区/命令行/面板)。实测绘图区 2110x462 -> 3310x1084(面积 3.68x),
restore 精确回位(窗口矩形/最大化态/绘图区尺寸全部还原)。

坑:CLEANSCREENOFF 是异步命令,AutoCAD 会按「进入干净屏幕时的布局」重设窗口几何,
把同步的 SetWindowPlacement 覆盖掉(表现为窗口停在最大化)。解法是插件内
SetTimer 延迟 700ms 再压回;实测窗口在 0.5~1.0s 之间收敛,故调用方等待取 1.5s。

取证层:
- 新增 maximized_drawing_area(ctx) 上下文管理器,__main__ 用它包住整个取证循环
  (多图框只进出一次);受 DRAWING_AREA_MAXIMIZE 控制,工具缺失/调用失败静默降级,
  restore 失败不掩盖业务异常。
- 内容过滤统一到规划与回退两条路径,共用同一谓词;过滤后为空则退回原集合。
- 判定改用「实体 bbox」而非「占用格」:格子量化会把跨界内容算进相邻格,反而更粗
  (实测实体 bbox 正好压象限边界时 4 个象限全判有内容)。heatmap.occupied_boxes 随之删除。

实测(真实图纸 A0/A1/A2 三图框):截图张数 12/8/4 -> 8/5/4;pytest 296 passed。
新增 verify_drawing_area.py 作为该工具的验收脚本。
This commit is contained in:
gjm committed 2026-10-15 23:44:32 +08:00
1 parent 9c4ab62dc1
commit 1e5bff268a
15 files changed
+657 -50

No files matched your search

+17 -14
View File
@@ -64,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(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))} 张"
)
# 截屏前把绘图区最大化(窗口铺满 + 干净屏幕),采集完在 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)
+13
View File
@@ -155,6 +155,19 @@ 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())
-9
View File
@@ -246,15 +246,6 @@ 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 = []
+74 -11
View File
@@ -7,6 +7,7 @@
(未来在对应分支产出**同形** evidence,下游无需改动)。
"""
import contextlib
import math
import time
@@ -16,6 +17,44 @@ 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 对象。"""
@@ -101,6 +140,32 @@ def _text_boxes(entities, texts, dimensions):
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):
"""尝试按实体分布规划瓦片窗口;任何条件不满足都返回 [](调用方退回固定策略)。
@@ -128,18 +193,11 @@ def _plan_tiles(window, frame, boxes, text_boxes, truncated, view_px):
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,
keep=_content_predicate(boxes),
)
@@ -149,7 +207,8 @@ def capture_images(ctx, window, *, tiles=0, max_images=None, frame=None,
- 整体 label="overall",局部 label="tile-1"..;行优先。
- 局部窗口优先由 `_plan_tiles` 按**实体分布**给出(空白区不出图);
不可用时退回 `sub_windows(window, tiles)`(行为与旧版完全一致)。
不可用时退回 `sub_windows(window, tiles)`,**同样按内容过滤**空白象限
(过滤后为空则退回未过滤集合,保证不漏拍)。
- 结束时**复位到整体视口**,保证后续流程从整幅视图起手。
- 受 max_images(默认 config.EVIDENCE_MAX_IMAGES)截断。
"""
@@ -166,10 +225,14 @@ def capture_images(ctx, window, *, tiles=0, max_images=None, frame=None,
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 = sub_windows(window, tiles)
regions = _apply_keep(sub_windows(window, tiles), keep)
for i, sub in enumerate(regions, 1):
if len(images) >= max_images: