"""离线标定 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())