415 lines
16 KiB
Python
415 lines
16 KiB
Python
"""离线标定 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())
|