Files
cad-agent/tests/test_tiling.py
T
gjm cba05b0c65 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 项全绿
2026-10-15 23:03:27 +08:00

209 lines
7.9 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.
"""`dwg_review.tiling` 单元测试(纯函数,不依赖 CAD / LLM / Pillow)。"""
from dwg_review import tiling as tl
def _frame(bbox, sheet_size="A0", sheet_scale=0.0):
return {"bbox": list(bbox), "sheet_size": sheet_size, "sheet_scale": sheet_scale}
def _cluster(bbox, count=10):
return {"bbox": tuple(float(v) for v in bbox), "cells": [], "count": count}
# --- norm_box / frame_bbox --------------------------------------------------
def test_norm_box_valid_and_invalid():
assert tl.norm_box([0, 0, 10, 20]) == (0.0, 0.0, 10.0, 20.0)
assert tl.norm_box([10, 20, 0, 0]) == (0.0, 0.0, 10.0, 20.0)
assert tl.norm_box([1, 2, 3]) is None
assert tl.norm_box(None) is None
assert tl.norm_box([0, 0, float("nan"), 1]) is None
def test_frame_bbox_rejects_degenerate_and_non_dict():
assert tl.frame_bbox(_frame((0, 0, 100, 50))) == (0.0, 0.0, 100.0, 50.0)
assert tl.frame_bbox(_frame((0, 0, 0, 50))) is None # 零宽
assert tl.frame_bbox(_frame((0, 0, 100, 0))) is None # 零高
assert tl.frame_bbox({"bbox": [1, 2, 3]}) is None
assert tl.frame_bbox(None) is None
def test_frame_world_size():
assert tl.frame_world_size(_frame((0, 0, 100, 50))) == (100.0, 50.0)
assert tl.frame_world_size(_frame((0, 0, 0, 0))) == (0.0, 0.0)
# --- frame_scale:三条路径 --------------------------------------------------
def test_frame_scale_prefers_sheet_scale():
assert tl.frame_scale(_frame((0, 0, 100, 50), sheet_scale=100.0)) == 100.0
def test_frame_scale_derived_landscape():
# A0 横放:世界 118900 x 84100 → 118900/1189 = 100
f = _frame((0, 0, 118900, 84100), sheet_size="A0")
assert tl.frame_scale(f) == 100.0
def test_frame_scale_derived_portrait():
# A0 纵放:世界宽 ↔ 纸高 841
f = _frame((0, 0, 84100, 118900), sheet_size="A0")
assert tl.frame_scale(f) == 100.0
def test_frame_scale_falls_back_to_default():
assert tl.frame_scale(_frame((0, 0, 100, 50), sheet_size="ZZ"), default=7.0) == 7.0
assert tl.frame_scale(_frame((0, 0, 0, 0)), default=7.0) == 7.0
assert tl.frame_scale(None, default=7.0) == 7.0
# --- effective_px / world_per_px -------------------------------------------
def test_effective_px_no_resize_when_small():
assert tl.effective_px((800, 600), 1280) == (800, 600)
def test_effective_px_downsamples_long_edge():
# 实测 CAD 绘图区 2110x462 → 长边缩到 1280
assert tl.effective_px((2110, 462), 1280) == (1280, 280)
def test_effective_px_invalid():
assert tl.effective_px(None, 1280) == (0, 0)
assert tl.effective_px((0, 100), 1280) == (0, 0)
assert tl.effective_px((100, 100), 0) == (100, 100) # 0 = 不缩放
def test_world_per_px():
# 1:100、纸面 2mm、VLM 需 10px → 每像素 20 图形单位
assert tl.world_per_px(100.0, 2.0, 10.0) == 20.0
assert tl.world_per_px(0, 2.0, 10.0) == 0.0
assert tl.world_per_px(100.0, 2.0, 0) == 0.0
# --- is_text_type -----------------------------------------------------------
def test_is_text_type_standard():
for t in ("TEXT", "MTEXT", "ATTRIB", "ATTDEF", "DIMENSION", "text"):
assert tl.is_text_type(t) is True
def test_is_text_type_custom_keywords():
for t in ("AECDLABEL", "AECDDIMTEXT", "MYNOTE", "PARTTAG", "ANNOTATION", "TITLEBLK"):
assert tl.is_text_type(t) is True
def test_is_text_type_negative():
for t in ("AECDBEWPIPE", "LINE", "LWPOLYLINE", "INSERT", "HATCH", "", None, 123):
assert tl.is_text_type(t) is False
# --- 相交 / 文字簇标记 -------------------------------------------------------
def test_boxes_intersect():
assert tl.boxes_intersect((0, 0, 10, 10), (5, 5, 15, 15)) is True
assert tl.boxes_intersect((0, 0, 10, 10), (10, 10, 20, 20)) is True # 接触算相交
assert tl.boxes_intersect((0, 0, 10, 10), (11, 11, 20, 20)) is False
assert tl.boxes_intersect(None, (0, 0, 1, 1)) is False
def test_cluster_text_flags():
clusters = [_cluster((0, 0, 10, 10)), _cluster((100, 100, 110, 110))]
flags = tl.cluster_text_flags(clusters, [(5, 5, 6, 6)])
assert flags == [True, False]
assert tl.cluster_text_flags(clusters, []) == [False, False]
assert tl.cluster_text_flags([], [(0, 0, 1, 1)]) == []
# --- tiles_for_box / subdivide ---------------------------------------------
def test_tiles_for_box_fits_single():
assert tl.tiles_for_box((0, 0, 1000, 500), 1000, 500) == (1, 1)
def test_tiles_for_box_dense():
assert tl.tiles_for_box((0, 0, 2000, 1000), 1000, 500) == (2, 2)
def test_tiles_for_box_overlap_increases_count():
assert tl.tiles_for_box((0, 0, 1000, 500), 1000, 500, overlap=0.1) == (2, 2)
def test_tiles_for_box_invalid():
assert tl.tiles_for_box(None, 10, 10) == (1, 1)
assert tl.tiles_for_box((0, 0, 0, 0), 10, 10) == (1, 1)
assert tl.tiles_for_box((0, 0, 10, 10), 0, 10) == (1, 1)
def test_subdivide_row_major_top_first():
wins = tl.subdivide((0, 0, 100, 100), 2, 2)
assert len(wins) == 4
assert wins[0] == {"min_x": 0.0, "min_y": 50.0, "max_x": 50.0, "max_y": 100.0}
assert wins[1] == {"min_x": 50.0, "min_y": 50.0, "max_x": 100.0, "max_y": 100.0}
assert wins[2] == {"min_x": 0.0, "min_y": 0.0, "max_x": 50.0, "max_y": 50.0}
assert wins[3] == {"min_x": 50.0, "min_y": 0.0, "max_x": 100.0, "max_y": 50.0}
def test_subdivide_overlap_expands_then_clamps():
wins = tl.subdivide((0, 0, 100, 100), 2, 2, overlap=0.1, frame_win=(0, 0, 100, 100))
# 每格 50x50,外扩 5 → 左上格:x 0..55(左被夹到 0),y 45..100(上被夹到 100)
assert wins[0] == {"min_x": 0.0, "min_y": 45.0, "max_x": 55.0, "max_y": 100.0}
# --- plan_regions -----------------------------------------------------------
def test_plan_regions_sparse_single_tile():
frame = _frame((0, 0, 1000, 500))
clusters = [_cluster((100, 100, 400, 300))]
wins = tl.plan_regions(frame, clusters, s_line=10.0, view_px=(100, 50))
assert len(wins) == 1
assert wins[0]["min_x"] <= 100 and wins[0]["max_x"] >= 400
def test_plan_regions_dense_splits():
frame = _frame((0, 0, 2000, 1000))
clusters = [_cluster((0, 0, 2000, 1000))]
wins = tl.plan_regions(frame, clusters, s_line=10.0, view_px=(100, 50))
assert len(wins) == 4 # 2x2
def test_plan_regions_text_uses_finer_scale():
frame = _frame((0, 0, 1000, 500))
clusters = [_cluster((0, 0, 1000, 500))]
line_only = tl.plan_regions(frame, clusters, s_line=10.0, view_px=(100, 50))
text = tl.plan_regions(frame, clusters, text_clusters={0}, s_text=5.0,
s_line=10.0, view_px=(100, 50))
assert len(line_only) == 1
assert len(text) == 4 # 半径减半 → 2x2
def test_plan_regions_text_cluster_prioritized_on_truncation():
frame = _frame((0, 0, 4000, 2000))
clusters = [_cluster((0, 0, 2000, 1000), count=99), # 非文字,排前面(输入序)
_cluster((2000, 1000, 4000, 2000), count=1)] # 文字
wins = tl.plan_regions(frame, clusters, text_clusters={1},
s_text=10.0, s_line=10.0, view_px=(100, 50), max_tiles=2)
assert len(wins) == 2
# 文字簇(下标 1,位于右上)应优先出图
assert wins[0]["min_x"] >= 2000 and wins[0]["min_y"] >= 1000
def test_plan_regions_degrades():
frame = _frame((0, 0, 1000, 500))
clusters = [_cluster((0, 0, 1000, 500))]
assert tl.plan_regions(frame, clusters, s_line=0.0, view_px=(100, 50)) == []
assert tl.plan_regions(frame, clusters, s_line=10.0, view_px=(0, 0)) == []
assert tl.plan_regions({"bbox": [0, 0, 0, 0]}, clusters, s_line=10.0,
view_px=(100, 50)) == []
assert tl.plan_regions(frame, [], s_line=10.0, view_px=(100, 50)) == []
def test_plan_regions_clamps_to_frame():
frame = _frame((0, 0, 1000, 500))
clusters = [_cluster((0, 0, 2000, 1000))] # 超出图框
wins = tl.plan_regions(frame, clusters, s_line=10.0, view_px=(100, 50), max_tiles=4)
for w in wins:
assert w["min_x"] >= 0 and w["max_x"] <= 1000
assert w["min_y"] >= 0 and w["max_y"] <= 500