Files
cad-agent/dwg_review/phases/evidence.py
T
gjm 9c4ab62dc1 feat(tiling): CAD 实测标定 R_text/R_line + 瓦片粒度内容过滤 + 默认参数定稿
Stage B2(CAD 实测标定)与收尾:

- 新增 verify_text_resolution.py:临时加字(字高 200 图形单位 = 1:100 纸面 2mm)+
  平行线对,按目标像素换算 zoom 窗口 → 截图 → 问 VLM;测完 delete_entities 清理不保存。
- 标定结论:
  * **可读下限与字体强相关**——离线合成(Consolas 抗锯齿填充字体)12px;
    真实 CAD 单线体 .shx(描边字体)6px 即可读对(3/3,两轮)。
  * 线条「1 条 vs 2 条」:2/3/4px 失败,5/8/12px 成功 → R_line ≈ 5px。
  * **必须用 ACI 7 白字**:实测红字(ACI 1)12px 因对比度不足完全读不出。
- config 定稿:TEXT_READABLE_PX=8(折中)、LINE_READABLE_PX=6、
  EVIDENCE_MAX_IMAGES=12(原 5 会把规划结果截到只剩 4 张瓦片)。
- 新增 heatmap.occupied_boxes:占用格的**世界 bbox**。
- tiling.plan_regions 新增 keep 谓词;evidence._plan_tiles 按占用格剔除**空瓦片**
  (簇 bbox 是包围盒,L 形/斜向分布含大片空白,实测有效)。
- 测试:test_heatmap 补 occupied_boxes;test_tiling 补 keep 过滤;全量 286 项全绿。
- 真实图纸端到端验证:A0 图框出 12 张(1 整体 + 11 瓦片),单框审查 1m38s 通过。
- 记忆库同步:工具语义坑(type_filter 必须传列表 / layer_filter 是通配字符串 /
  add_* 返回句柄在 created 键)、标定结论与默认值。
2026-10-15 23:22:15 +08:00

243 lines
9.1 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 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"
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 _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}
# 内容过滤:簇 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,
)
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)
# 局部:优先自适应瓦片,失败退回等分象限
regions = _plan_tiles(window, frame, boxes, text_boxes, truncated, view_px)
if not regions:
regions = sub_windows(window, tiles)
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