feat(tiling): 自适应瓦片规划器 + VLM 分辨率离线标定

背景:逐图框审查固定「整体 + 2x2 象限」截图,大图幅(A0/A1)读不清细节。
改为按**实体分布**自适应决定截几张、截哪里。

- 新增 dwg_review/tiling.py(纯函数):
  SHEET_SIZES_MM / frame_world_size / frame_scale(sheet_scale>0 用之,否则由
  bbox/纸张反算,否则 default)/ effective_px(与 downsample 同规则)/
  world_per_px / is_text_type(标准名 + 自定义类名含 TEXT/LABEL/DIM/NOTE/TAG/
  ANNO/TITLE)/ cluster_text_flags / tiles_for_box / subdivide / plan_regions
  (文字簇优先、空白区不出图、max_tiles 截断、窗口夹到图框范围)
- 新增 verify_vlm_resolution_offline.py:PIL 合成类图纸图(精确像素字高 +
  可控线间距/线宽/虚线)→ 问 VLM 强约束 JSON → 定 R_text / R_line。
  实测:R_text=12px(黑底;白底 10px);R_line=6px。
  另:实测该模型**无法可靠计数**(6 条线在 2~10px 间距下答 4/10/4/5/4/4),
  故线条判据改用「1 条 vs 2 条」二选一。
- config 新增 TILING_ENABLED / TEXT_HEIGHT_MM / TEXT_READABLE_PX=12 /
  LINE_READABLE_PX=6 / TILE_MAX_COUNT / TILE_OVERLAP / HEATMAP_CELL_DIV /
  HEATMAP_CLUSTER_DILATE / HEATMAP_MAX_AREA_FRAC;EVIDENCE_DETAIL_TILES 与
  EVIDENCE_MAX_IMAGES 保留作回退路径参数
- llm_client 新增 image_size_b64(测量绘图区实际像素,决定单张覆盖范围)
- phases/evidence.capture_images 支持瓦片规划:先截整体图并测像素 →
  plan_regions 成功用规划窗口,否则退回 sub_windows(行为零变化);
  末尾始终复位整体视口。collect_frame 传实体/文字 bbox;任一信封
  total>returned(截断)→ 退回固定策略,避免误删有内容的瓦片
- 测试:tests/test_tiling.py(28 项)、tests/test_evidence.py 新增规划路径与
  降级用例、test_config / test_llm_client 补新常量与 image_size_b64;
  全量 283 项全绿
This commit is contained in:
gjm committed 2026-10-15 23:03:27 +08:00
1 parent 9b8a2329df
commit cba05b0c65
9 files changed
+1246 -10

No files matched your search

+97 -9
View File
@@ -10,7 +10,8 @@
import math
import time
from .. import config, evidence_model
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"
@@ -66,10 +67,80 @@ def _zoom_and_shot(ctx, window, margin=0.02):
return images[0]
def capture_images(ctx, window, *, tiles=0, max_images=None):
"""采集视觉证据:整体一张 + 局部 tiles 张。
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}
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,
)
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)截断。
"""
@@ -81,11 +152,17 @@ def capture_images(ctx, window, *, tiles=0, max_images=None):
if shot and len(images) < max_images:
images.append(evidence_model.make_image(shot[0], shot[1], label))
# 整体
_add(_zoom_and_shot(ctx, window), _LABEL_OVERALL)
# 整体图:既是证据,也用于测量绘图区**实际像素**(决定单张截图的世界覆盖范围)
overall = _zoom_and_shot(ctx, window)
view_px = image_size_b64(overall[0]) if overall else None
_add(overall, _LABEL_OVERALL)
# 局部(等分象限)
for i, sub in enumerate(sub_windows(window, tiles), 1):
# 局部:优先自适应瓦片,失败退回等分象限
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}")
@@ -115,8 +192,19 @@ def collect_frame(ctx, index, frame):
ctx.tools.call("get_polylines_info", {"window": win, "limit": limit}), {}
)
# b. 视觉:整体 + 可选局部(多图)
images = capture_images(ctx, win, tiles=config.EVIDENCE_DETAIL_TILES)
# 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),