Files
cad-agent/dwg_review/tiling.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

289 lines
10 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.
"""自适应瓦片规划器:按实体分布把图框切成若干 zoom 窗口,使 VLM 能看清细节。
纯函数模块(仅依赖 `math`),不依赖 Pillow / CAD。
核心思路(用户方案):
1. 由图框纸张 + 比例换算「CAD 图形单位 / 像素」s;
2. 由 VLM 可读下限(文字 `R_text` px、线条 `R_line` px)定每张截图的**世界覆盖范围**;
3. 复用热力图(`heatmap.py`)的**占用网格 + 聚类**得到簇包围盒;簇内是否含**文字类实体**
决定用细(`s_text`)/ 粗(`s_line`)尺度;
4. 簇 ≤ 单屏 → 1 张;超出 → 行优先细分;**空白区不出图**(整体图已覆盖);
受 `max_tiles` 截断,**文字簇优先保留**。
坐标约定:与 `heatmap.py` / CAD 一致——x 向右、y **向上**;bbox=(min_x,min_y,max_x,max_y);
瓦片行优先且 row 0 在**最上方**(与 `phases/evidence.py::sub_windows` 一致)。
"""
import math
# 纸张尺寸(mm,长边在前):GB/T 14689 基本幅面
SHEET_SIZES_MM = {
"A0": (1189.0, 841.0),
"A1": (841.0, 594.0),
"A2": (594.0, 420.0),
"A3": (420.0, 297.0),
"A4": (297.0, 210.0),
}
# 标准实体名:本身就是文字/标注类
_STD_TEXT_TYPES = frozenset({"TEXT", "MTEXT", "ATTRIB", "ATTDEF", "DIMENSION"})
# 自定义实体类名关键词(用户经验:类名常含 text/label/dim 等)
_TEXT_KEYWORDS = ("TEXT", "LABEL", "DIM", "NOTE", "TAG", "ANNO", "TITLE")
# 瓦片重叠上限(防边界切字;过大会显著增加张数)
_MAX_OVERLAP = 0.5
def norm_box(b):
"""归一 bbox 为 (minx,miny,maxx,maxy);非法返回 None。"""
if not isinstance(b, (list, tuple)) or len(b) != 4:
return None
try:
x1, y1, x2, y2 = (float(v) for v in b)
except (TypeError, ValueError):
return None
if not all(math.isfinite(v) for v in (x1, y1, x2, y2)):
return None
if x1 > x2:
x1, x2 = x2, x1
if y1 > y2:
y1, y2 = y2, y1
return (x1, y1, x2, y2)
def frame_bbox(frame):
"""图框 bbox 元组;非法返回 None。"""
if not isinstance(frame, dict):
return None
box = norm_box(frame.get("bbox"))
if box is None or box[2] <= box[0] or box[3] <= box[1]:
return None
return box
def frame_world_size(frame):
"""图框在 CAD 中的世界尺寸 (W, H);bbox 非法返回 (0.0, 0.0)。"""
box = frame_bbox(frame)
if box is None:
return (0.0, 0.0)
return (box[2] - box[0], box[3] - box[1])
def frame_scale(frame, default=0.0):
"""图框出图比例(1:100 → 100.0)。
优先级:
1. `sheet_scale`(工具已识别,>0 直接用);
2. 由「图框世界尺寸 / 纸张尺寸」反算(需 `sheet_size` 命中纸张表);
3. `default`(拿不到 → 调用方退回固定策略)。
"""
if not isinstance(frame, dict):
return default
try:
s = float(frame.get("sheet_scale") or 0.0)
except (TypeError, ValueError):
s = 0.0
if s > 0:
return s
w, h = frame_world_size(frame)
if w <= 0 or h <= 0:
return default
sheet = SHEET_SIZES_MM.get(str(frame.get("sheet_size") or "").strip().upper())
if not sheet:
return default
sw, sh = sheet
# 判断横放 / 纵放:取长宽比更接近的朝向
ar = w / h
if abs(ar - sw / sh) <= abs(ar - sh / sw):
return w / sw # 横放:世界宽 ↔ 纸宽
return w / sh # 纵放:世界宽 ↔ 纸高
def effective_px(view_px, max_edge):
"""按长边下采样后的**有效像素** (ew, eh)。
与 `llm_client.downsample_image_b64` 的缩放规则一致(长边缩到 max_edge)。
非法输入返回 (0, 0);max_edge<=0 表示不缩放。
"""
if not view_px or len(view_px) != 2:
return (0, 0)
try:
w, h = int(view_px[0]), int(view_px[1])
except (TypeError, ValueError):
return (0, 0)
if w <= 0 or h <= 0:
return (0, 0)
if max_edge and max_edge > 0 and max(w, h) > max_edge:
k = max_edge / float(max(w, h))
return (max(1, int(round(w * k))), max(1, int(round(h * k))))
return (w, h)
def world_per_px(scale, text_mm, readable_px):
"""每像素代表的 CAD 图形单位:s = (text_mm * scale) / readable_px。
`text_mm` 为纸面字高(国标 2~3mm),`scale` 为出图比例(1:100 → 100),
`readable_px` 为 VLM 可读的**最小像素字高**。任一非正 → 返回 0.0(表示无法规划)。
"""
try:
scale, text_mm, readable_px = float(scale), float(text_mm), float(readable_px)
except (TypeError, ValueError):
return 0.0
if scale <= 0 or text_mm <= 0 or readable_px <= 0:
return 0.0
return text_mm * scale / readable_px
def is_text_type(type_name):
"""实体类型名是否为「文字/标注类」。
标准实体给 DXF 名(TEXT/MTEXT/ATTRIB/ATTDEF/DIMENSION)→ 精确命中;
自定义实体兜底为**类名去 AcDb 前缀并大写**,故按**关键词包含**判定
(TEXT/LABEL/DIM/NOTE/TAG/ANNO/TITLE)。非字符串/空 → False。
"""
if not isinstance(type_name, str):
return False
t = type_name.strip().upper()
if not t:
return False
if t in _STD_TEXT_TYPES:
return True
return any(k in t for k in _TEXT_KEYWORDS)
def boxes_intersect(a, b, eps=0.0):
"""两个 bbox 是否相交(接触视为相交;eps 可放宽)。"""
if a is None or b is None:
return False
return not (
a[2] < b[0] - eps or b[2] < a[0] - eps
or a[3] < b[1] - eps or b[3] < a[1] - eps
)
def cluster_text_flags(clusters, text_boxes):
"""标记每个簇是否含文字类实体(bbox 相交即算,保守)。
返回与 `clusters` 等长的 bool 列表。
"""
boxes = [b for b in (norm_box(x) for x in (text_boxes or [])) if b]
flags = []
for cl in clusters or []:
b = norm_box(cl.get("bbox")) if isinstance(cl, dict) else None
flags.append(bool(b) and any(boxes_intersect(b, tb) for tb in boxes))
return flags
def tiles_for_box(box, cap_w, cap_h, overlap=0.0):
"""覆盖 box 所需的 (cols, rows)。
`cap_w`/`cap_h` 为单张截图的世界覆盖范围;`overlap` 为相邻瓦片重叠比例
(>0 时有效步长缩短,张数增加)。非法输入返回 (1, 1)。
"""
if box is None:
return (1, 1)
w, h = box[2] - box[0], box[3] - box[1]
if w <= 0 or h <= 0:
return (1, 1)
ov = min(max(float(overlap or 0.0), 0.0), _MAX_OVERLAP)
step_w = float(cap_w) * (1.0 - ov)
step_h = float(cap_h) * (1.0 - ov)
if step_w <= 0 or step_h <= 0:
return (1, 1)
return (max(1, int(math.ceil(w / step_w))), max(1, int(math.ceil(h / step_h))))
def _clamp_window(win, frame_win):
"""把窗口夹到图框范围内;夹后退化(宽/高<=0)返回 None。"""
if frame_win is None:
return win
fx1, fy1, fx2, fy2 = frame_win
x1 = max(win["min_x"], fx1)
y1 = max(win["min_y"], fy1)
x2 = min(win["max_x"], fx2)
y2 = min(win["max_y"], fy2)
if x2 <= x1 or y2 <= y1:
return None
return {"min_x": x1, "min_y": y1, "max_x": x2, "max_y": y2}
def subdivide(box, cols, rows, overlap=0.0, frame_win=None):
"""把 box 等分为 cols×rows 个 zoom 窗口(行优先,row 0 在最上方)。
每张按 `overlap` 比例向外扩张(防边界切字),再夹到 `frame_win`。
"""
x1, y1, x2, y2 = box
cols, rows = max(1, int(cols)), max(1, int(rows))
w = (x2 - x1) / cols
h = (y2 - y1) / rows
ov = min(max(float(overlap or 0.0), 0.0), _MAX_OVERLAP)
mx, my = w * ov, h * ov
out = []
for r in range(rows):
for c in range(cols):
sx1 = x1 + c * w
sy2 = y2 - r * h # row 0 在最上方
win = {
"min_x": sx1 - mx, "min_y": sy2 - h - my,
"max_x": sx1 + w + mx, "max_y": sy2 + my,
}
clamped = _clamp_window(win, frame_win)
if clamped:
out.append(clamped)
return out
def plan_regions(frame, clusters, *, text_clusters=(), s_text=0.0, s_line=0.0,
view_px=(0, 0), max_tiles=36, overlap=0.0, keep=None):
"""把图框内的簇规划成一组 zoom 窗口(自适应瓦片)。
参数:
- `frame` :图框(含 bbox / sheet_size / sheet_scale);
- `clusters` :`heatmap.cluster_cells` 的输出(已按实体计数降序);
- `text_clusters`:含文字类实体的**簇下标**集合(决定用 s_text 还是 s_line);
- `s_text`/`s_line`:CAD 图形单位 / 像素(`world_per_px` 的输出);
- `view_px` :单张截图的**有效像素** (ew, eh)(`effective_px` 的输出);
- `max_tiles` :返回窗口数硬上界(文字簇优先保留);
- `overlap` :相邻瓦片重叠比例;
- `keep` :可选谓词 `f(window) -> bool`,返回 False 的瓦片被丢弃。
用于按**占用格**剔除簇 bbox 内的空白瓦片(簇 bbox 对 L 形/斜向分布含大片空白)。
返回行优先的 window 列表;**无法规划时返回 []**(调用方退回固定策略)。
"""
frame_win = frame_bbox(frame)
if frame_win is None:
return []
ew, eh = view_px[0] if view_px else 0, view_px[1] if view_px and len(view_px) > 1 else 0
if ew <= 0 or eh <= 0:
return []
text_idx = set(text_clusters or ())
# 文字簇优先(同级内保持输入的计数降序)
ordered = sorted(
enumerate(clusters or []),
key=lambda p: (0 if p[0] in text_idx else 1, p[0]),
)
regions = []
for i, cl in ordered:
if len(regions) >= max_tiles:
break
box = norm_box(cl.get("bbox")) if isinstance(cl, dict) else None
if box is None:
continue
s = s_text if i in text_idx else s_line
if s <= 0:
continue
cap_w, cap_h = s * ew, s * eh
cols, rows = tiles_for_box(box, cap_w, cap_h, overlap)
# 单屏可覆盖 → 1 张(subdivide 仍会按 overlap 外扩并夹到图框)
for win in subdivide(box, cols, rows, overlap, frame_win):
if keep is not None and not keep(win):
continue # 该瓦片落在空白区,不出图
regions.append(win)
if len(regions) >= max_tiles:
break
return regions[:max_tiles]