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

416 lines
16 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.
"""离线标定 VLM 读图下限(不碰 CAD,纯 PIL 合成)。
目的:在接入自适应瓦片规划前,先离线测出视觉模型(deepseek-flash)的
「最小可读文字像素高 `R_text`」与「最小可分辨线间距 `R_line`」,为 `tiling.py`
的 `world_per_px` 提供**区间参考**。
做法:PIL 合成"类图纸"图(可控背景极性),**精确控制像素字高**(先用
`font.getbbox` 二分出目标 ink 高度对应的字号),加可控间距/线宽的平行线,
以及实线 / 虚线对比。每档发 VLM,提示词强约束 JSON,与真值比对。
重要前提:**合成图比真实 CAD 渲染干净 → 结论偏乐观**,只作区间;最终以
`verify_text_resolution.py`(CAD 临时加字实测)为准。
用法:
python verify_vlm_resolution_offline.py --dry-run # 只出图,不调 VLM
python verify_vlm_resolution_offline.py --repeat 2 # 每档重复 2 次
python verify_vlm_resolution_offline.py --heights 4,6,8,10,12 --spacings 2,3,4,6,8
python verify_vlm_resolution_offline.py --polarity light # 白底黑线
前置:需要 Pillow;调 VLM 需要 DEEPSEEK_API_KEY(--dry-run 不需要密钥)。
"""
import argparse
import base64
import io
import json
import math
import random
import re
from pathlib import Path
from dwg_review import config
from dwg_review.llm_client import HAS_PIL, LlmClient, downsample_image_b64
# 画布宽固定为 1280(= IMAGE_MAX_EDGE),使发给 VLM 前**不发生下采样**,
# 与真实管线「截图长边缩到 1280」后的条件一致。
_CANVAS_W = 1280
_MIN_CANVAS_H = 280
# 易混字符集:去掉 I / O,避免"真值本身歧义"污染标定
_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
_DARK = {"bg": (0, 0, 0), "fg": (255, 255, 255)}
_LIGHT = {"bg": (255, 255, 255), "fg": (0, 0, 0)}
_POLARITY = {"dark": _DARK, "light": _LIGHT}
_FONT_CANDIDATES = (
r"C:\Windows\Fonts\consola.ttf", # 等宽,字高最稳
r"C:\Windows\Fonts\cour.ttf",
r"C:\Windows\Fonts\lucon.ttf",
r"C:\Windows\Fonts\arial.ttf",
r"/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
r"/System/Library/Fonts/Menlo.ttc",
)
_PROBE = "K7Q2M9" # 全为大写/数字,ink 高度一致,用于二分字号
_SYS_TEXT = (
"你是图像文字识别器。只输出一个 JSON 对象,不要任何解释、不要 markdown 代码块。"
)
_SYS_LINE = (
"你是工程图纸线型识别器。只输出一个 JSON 对象,不要任何解释、不要 markdown 代码块。"
)
# ---------------------------------------------------------------------------
# 字体与像素字高
# ---------------------------------------------------------------------------
def find_font_path(explicit=None):
if explicit:
p = Path(explicit)
if p.exists():
return str(p)
raise SystemExit(f"指定的字体不存在:{explicit}")
for cand in _FONT_CANDIDATES:
if Path(cand).exists():
return cand
raise SystemExit("未找到可用 TTF 字体,请用 --font 指定")
def font_for_ink_height(target_h, font_path, probe=_PROBE):
"""二分出使 `probe` 的 ink 高度最接近 target_h 的字体。
返回 (font, size, actual_h)。ink 高度 = getbbox 的 (bottom - top)。
"""
from PIL import ImageFont
target_h = max(1, int(target_h))
lo, hi = 1, 400
best = None # (font, size, h)
while lo <= hi:
mid = (lo + hi) // 2
font = ImageFont.truetype(font_path, mid)
bb = font.getbbox(probe)
h = bb[3] - bb[1]
if best is None or abs(h - target_h) < abs(best[2] - target_h):
best = (font, mid, h)
if h == target_h:
break
if h < target_h:
lo = mid + 1
else:
hi = mid - 1
return best
# ---------------------------------------------------------------------------
# 合成图
# ---------------------------------------------------------------------------
def _canvas(h, polarity):
from PIL import Image
pal = _POLARITY[polarity]
return Image.new("RGB", (_CANVAS_W, max(_MIN_CANVAS_H, int(h))), pal["bg"])
def synth_text_image(height_px, tokens, font_path, *, polarity="dark", left=90):
"""合成 N 行等高文字(每行一个随机串)。返回 (image, actual_h)。"""
from PIL import ImageDraw
font, _size, actual_h = font_for_ink_height(height_px, font_path)
n = len(tokens)
pitch = max(actual_h + 3, int(round(actual_h * 3)))
block_h = pitch * (n - 1) + actual_h
canvas_h = block_h + 4 * actual_h + 60
img = _canvas(canvas_h, polarity)
draw = ImageDraw.Draw(img)
fg = _POLARITY[polarity]["fg"]
y_top = (img.height - block_h) // 2
for i, tok in enumerate(tokens):
bb = font.getbbox(tok)
# getbbox 的 top 可能为负(字体 ascent 内留白),补偿使 ink 顶对齐 y_top
draw.text((left, y_top - bb[1]), tok, font=font, fill=fg)
y_top += pitch
return img, actual_h
def synth_lines_image(spacing_px, count, *, width=1, polarity="dark",
x0=200, x1=1080, dashed=False, dash=(6, 4)):
"""合成 count 条水平平行线,间距 spacing_px,线宽 width。返回 image。"""
from PIL import ImageDraw
spacing_px = max(1, int(spacing_px))
block_h = (count - 1) * spacing_px
canvas_h = max(block_h + 8 * max(spacing_px, 4), 2 * block_h + 60)
img = _canvas(canvas_h, polarity)
draw = ImageDraw.Draw(img)
fg = _POLARITY[polarity]["fg"]
y0 = (img.height - block_h) // 2
for i in range(count):
y = y0 + i * spacing_px
if dashed:
_draw_dashed(draw, x0, x1, y, dash[0], dash[1], fg, width)
else:
draw.line([(x0, y), (x1, y)], fill=fg, width=width)
return img
def _draw_dashed(draw, x0, x1, y, on, off, color, width):
x = x0
while x < x1:
draw.line([(x, y), (min(x + on, x1), y)], fill=color, width=width)
x += on + off
def _to_b64(img, max_edge=None):
buf = io.BytesIO()
img.save(buf, format="PNG")
b64 = base64.b64encode(buf.getvalue()).decode("ascii")
return downsample_image_b64(b64, "image/png", max_edge=max_edge)
# ---------------------------------------------------------------------------
# VLM 交互
# ---------------------------------------------------------------------------
def _extract_json(text):
"""从模型输出里抠出第一个 JSON 对象(容忍 ```json 包裹与前后噪声)。"""
if not text:
return None
m = re.search(r"\{.*\}", text, re.S)
if not m:
return None
try:
return json.loads(m.group(0))
except json.JSONDecodeError:
return None
def ask_vlm(client, img, system, prompt, *, max_edge=None):
"""发一张图问 VLM,返回解析后的 dict(失败返回 None)。"""
b64, mime = _to_b64(img, max_edge)
resp = client.chat([
{"role": "system", "content": system},
{"role": "user", "content": [
{"type": "text", "text": prompt},
LlmClient.image_content(b64, mime),
]},
])
return _extract_json(resp.choices[0].message.content)
def _norm_token(s):
return re.sub(r"[^A-Z0-9]", "", str(s).upper())
def _lenient(s):
"""宽松归一:常见混淆 0<->O、1<->I<->L。"""
s = _norm_token(s)
return s.replace("0", "O").replace("1", "I").replace("L", "I")
# ---------------------------------------------------------------------------
# 各子测试
# ---------------------------------------------------------------------------
def run_text_level(client, height_px, font_path, polarity, repeat, out_dir, dry_run):
rng = random.Random(1000 + height_px)
tokens = ["".join(rng.choice(_ALPHABET) for _ in range(6)) for _ in range(5)]
img, actual_h = synth_text_image(height_px, tokens, font_path, polarity=polarity)
png = out_dir / f"text_h{height_px}_{polarity}.png"
img.save(str(png))
prompt = (
"图中是若干行**等高的白色字符**(黑底)。请逐行读出,按从上到下顺序输出 JSON:"
'{"texts":["第一行","第二行",...]}。无法辨认的行填空字符串。'
)
if polarity == "light":
prompt = prompt.replace("白色字符**(黑底)", "黑色字符**(白底)")
trials = []
if not dry_run:
for _ in range(max(1, repeat)):
data = ask_vlm(client, img, _SYS_TEXT, prompt)
got = list(data.get("texts") or []) if isinstance(data, dict) else []
got = [str(x) for x in got]
exact = sum(1 for i, t in enumerate(tokens) if i < len(got) and _norm_token(got[i]) == t)
lenient = sum(1 for i, t in enumerate(tokens) if i < len(got) and _lenient(got[i]) == _lenient(t))
trials.append({"got": got, "exact": exact, "lenient": lenient})
n = len(tokens)
best_exact = max((t["exact"] for t in trials), default=0)
return {
"height_px": height_px, "actual_h": actual_h, "tokens": tokens,
"trials": trials, "n": n, "best_exact": best_exact,
"readable": best_exact == n, # 全部行都对 → 可读
"mostly": best_exact >= n - 1, # 容错 1 行
"png": png.name,
}
def _ask_count(client, img, prompt):
data = ask_vlm(client, img, _SYS_LINE, prompt)
if not isinstance(data, dict):
return None
try:
return int(data.get("count"))
except (TypeError, ValueError):
return None
def run_line_level(client, spacing, polarity, repeat, out_dir, dry_run):
"""1 vs 2 **二选一**(比计数稳健得多):间距 spacing 的两条细线 vs 一条等宽粗线。
实测该模型**无法可靠计数**(6 条线在 2~10px 间距下答 4/10/4/5/4/4),
故线条判据只测「相邻线能否被分辨开」这一最核心的细节能力。
"""
pair = synth_lines_image(spacing, 2, width=1, polarity=polarity)
single = synth_lines_image(20, 1, width=max(1, int(spacing)), polarity=polarity)
pair_png = out_dir / f"pair_s{spacing}_{polarity}.png"
single_png = out_dir / f"single_s{spacing}_{polarity}.png"
pair.save(str(pair_png))
single.save(str(single_png))
prompt = (
"图中是**水平直线**(黑底白线)。只判断线条数量是 1 还是 2,"
'输出 JSON:{"count": 1 或 2}。'
)
if polarity == "light":
prompt = prompt.replace("黑底白线", "白底黑线")
trials = []
if not dry_run:
for _ in range(max(1, repeat)):
g_pair = _ask_count(client, pair, prompt)
g_single = _ask_count(client, single, prompt)
trials.append({"pair": g_pair, "single": g_single,
"ok": g_pair == 2 and g_single == 1})
return {
"spacing": spacing, "truth_pair": 2, "truth_single": 1,
"trials": trials, "resolved": any(t["ok"] for t in trials),
"png_pair": pair_png.name, "png_single": single_png.name,
}
def run_dashed_test(client, polarity, repeat, out_dir, dry_run):
"""虚线 vs 实线识别(两条线,避开计数干扰)。"""
out = []
for dashed in (False, True):
img = synth_lines_image(12, 2, width=1, polarity=polarity,
dashed=dashed, dash=(8, 6))
png = out_dir / f"dashed_{'on' if dashed else 'off'}_{polarity}.png"
img.save(str(png))
prompt = (
"图中是两条**水平直线**(黑底白线)。判断它们是否为虚线,"
'输出 JSON:{"dashed": true 或 false}。'
)
if polarity == "light":
prompt = prompt.replace("黑底白线", "白底黑线")
trials = []
if not dry_run:
for _ in range(max(1, repeat)):
data = ask_vlm(client, img, _SYS_LINE, prompt)
got = data.get("dashed") if isinstance(data, dict) else None
if isinstance(got, str):
got = got.strip().lower() in ("true", "1", "yes", "是")
trials.append({"got_dashed": got, "ok": got is dashed})
out.append({"truth_dashed": dashed, "trials": trials,
"correct": any(t["ok"] for t in trials), "png": png.name})
return out
# ---------------------------------------------------------------------------
# 主流程
# ---------------------------------------------------------------------------
def _parse_ints(s):
return [int(x) for x in str(s).split(",") if x.strip()]
def main(argv=None):
ap = argparse.ArgumentParser()
ap.add_argument("--heights", default="4,6,8,10,12,16,20,24", help="文字像素高扫描档")
ap.add_argument("--spacings", default="2,3,4,5,6,8,10", help="线间距(px)扫描档")
ap.add_argument("--polarity", choices=("dark", "light", "both"), default="dark",
help="背景极性:dark=黑底白线(CAD 模型空间默认),light=白底黑线")
ap.add_argument("--repeat", type=int, default=1, help="每档重复次数(取多数/任一成功)")
ap.add_argument("--font", default=None, help="TTF 字体路径(默认自动查找等宽字体)")
ap.add_argument("--max-edge", type=int, default=None, help="发给 VLM 前长边上限(默认 config)")
ap.add_argument("--dry-run", action="store_true", help="只合成图并落盘,不调用 VLM")
ap.add_argument("--out", default="reports/_probe/vlm_calib")
args = ap.parse_args(argv)
if not HAS_PIL:
raise SystemExit("离线标定需要 Pillow:pip install pillow")
font_path = find_font_path(args.font)
out_dir = Path(config.PROJECT_ROOT) / args.out
out_dir.mkdir(parents=True, exist_ok=True)
polarities = ("dark", "light") if args.polarity == "both" else (args.polarity,)
client = None
if not args.dry_run:
client = LlmClient()
print(f"模型:{client.model} 字体:{font_path}")
heights = _parse_ints(args.heights)
spacings = _parse_ints(args.spacings)
results = {"polarity": list(polarities), "text": {}, "line": {}, "dashed": {}}
for pol in polarities:
print(f"\n===== 极性 {pol} =====")
print("-- 文字可读性 --")
text_rows = []
for h in heights:
row = run_text_level(client, h, font_path, pol, args.repeat, out_dir, args.dry_run)
text_rows.append(row)
if args.dry_run:
print(f" 字高 {h:>3}px (实际 {row['actual_h']}px) -> {row['png']}")
else:
print(f" 字高 {h:>3}px (实际 {row['actual_h']}px) 命中 "
f"{row['best_exact']}/{row['n']} 可读={row['readable']}")
results["text"][pol] = text_rows
print("-- 线间距可分辨性(1 vs 2 二选一) --")
line_rows = []
for s in spacings:
row = run_line_level(client, s, pol, args.repeat, out_dir, args.dry_run)
line_rows.append(row)
if args.dry_run:
print(f" 间距 {s:>3}px -> {row['png_pair']} / {row['png_single']}")
else:
print(f" 间距 {s:>3}px 可分辨={row['resolved']} {row['trials']}")
results["line"][pol] = line_rows
print("-- 虚线识别 --")
d = run_dashed_test(client, pol, args.repeat, out_dir, args.dry_run)
results["dashed"][pol] = d
if not args.dry_run:
for item in d:
print(f" 虚线={item['truth_dashed']} 识别正确={item['correct']}")
# 汇总(取两极性中更优者作为"区间参考")
summary = {}
for pol in polarities:
text_rows = results["text"].get(pol, [])
line_rows = results["line"].get(pol, [])
r_text = next((r["height_px"] for r in text_rows if r["readable"]), None)
r_text_mostly = next((r["height_px"] for r in text_rows if r["mostly"]), None)
r_line = next((r["spacing"] for r in line_rows if r["resolved"]), None)
summary[pol] = {"R_text": r_text, "R_text_mostly": r_text_mostly, "R_line": r_line}
print(f"\n[{pol}] R_text(全部行正确)={r_text} R_text(容错1行)={r_text_mostly} "
f"R_line={r_line}")
results["summary"] = summary
(out_dir / "summary.json").write_text(
json.dumps(results, ensure_ascii=False, indent=2), encoding="utf-8"
)
print(f"\n输出目录: {out_dir}")
print("注意:合成图偏干净,结论偏乐观,仅作区间;以 CAD 实测 (verify_text_resolution.py) 为准。")
return 0
if __name__ == "__main__":
raise SystemExit(main())