Files
cad-agent/dwg_review/phases/evidence.py
T
gjm 1e5bff268a 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 作为该工具的验收脚本。
2026-10-15 23:44:32 +08:00

306 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.
"""阶段 2:逐审查单元取证(多源证据层 + 视觉多图)。
对每个审查单元:取结构化数据(实体/文字/标注/块/多段线)+ 视觉截图(整体 + 可选局部)。
结构化数据是权威依据,截图仅作视觉上下文。
多源设计:`collect()` 按审查单元来源分发;当前仅实现 CAD 源,PDF / 图片源预留 seam
(未来在对应分支产出**同形** evidence,下游无需改动)。
"""
import contextlib
import math
import time
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 对象。"""
return {
"min_x": float(bbox[0]),
"min_y": float(bbox[1]),
"max_x": float(bbox[2]),
"max_y": float(bbox[3]),
}
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):
"""采集单个图框(CAD 源)的证据;bbox 无效时返回 None。"""
bbox = frame.get("bbox")
if not bbox or len(bbox) != 4:
return None
win = bbox_to_window(bbox)
limit = ctx.frame_limit
# 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}), {})
blocks = result_json(
ctx.tools.call("get_block_references", {"window": win, "limit": limit}), {}
)
polylines = result_json(
ctx.tools.call("get_polylines_info", {"window": win, "limit": limit}), {}
)
# 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,
"entities": entities,
"texts": texts,
"dimensions": dimensions,
"blocks": blocks,
"polylines": polylines,
"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