# -*- coding: utf-8 -*- """ verify_pdf_source.py PDF / 图片**离线源**验收脚本。**不需要 CAD、不需要 8080 端口**(这是本方向的核心卖点)。 用法: python verify_pdf_source.py "E:\\path\\图纸.pdf" python verify_pdf_source.py "E:\\path\\图纸.pdf" --pages 3 # 只验前 3 页 python verify_pdf_source.py "E:\\path\\scan.png" # 图片源 python verify_pdf_source.py "E:\\path\\图纸.pdf" --dump out/c1 # 导出图片供目视 python verify_pdf_source.py "E:\\path\\图纸.pdf" --llm # 追加真实 LLM 端到端 覆盖: 0) 离线:全程 ctx.tools is None / ctx.client is None(绝不连 CAD) 1) 规划:页数、页序保持、每页 bbox / 幅面反查 2) 取证:同形 evidence(空结构化信封)、文本层、整体图 + 墨迹瓦片 3) 分辨率自检:**实测**每张图的「纸面字高像素数」,不得低于线条可读下限 4) 旋转自检(/Rotate ≠ 0 且有文本层时):文本矩形处的墨迹密度显著高于错误映射 5) --dump:导出 PNG 供人工目视 6) --llm :跑真实 `python -m dwg_review `,检查报告落盘 """ import base64 import io import os import subprocess import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) from PIL import Image # noqa: E402 from dwg_review import config, evidence_model, sources, tiling # noqa: E402 from dwg_review.context import ReviewContext # noqa: E402 from dwg_review.phases import evidence as evidence_phase # noqa: E402 from dwg_review.phases import planning # noqa: E402 RESULTS = [] def record(name, ok, detail=""): RESULTS.append((name, ok, detail)) print(" [%s] %-34s %s" % ("PASS" if ok else "FAIL", name, detail)) def info(msg): print(" %s" % msg) # ---------------------------------------------------------------- 小工具 def dark_frac(img, threshold=None): """一张图里「暗像素」(墨迹)的占比。纯 PIL,无 numpy。""" threshold = config.INK_THRESHOLD if threshold is None else threshold bw = img.convert("L").point(lambda p: 255 if p < threshold else 0, mode="L") hist = bw.histogram() total = sum(hist) or 1 return hist[255] / float(total) def clip_box(box, page_box): """把 bbox 夹到页范围内;退化返回 None。""" x1 = max(box[0], page_box[0]) y1 = max(box[1], page_box[1]) x2 = min(box[2], page_box[2]) y2 = min(box[3], page_box[3]) return None if x2 <= x1 or y2 <= y1 else (x1, y1, x2, y2) def png_size(b64): return Image.open(io.BytesIO(base64.b64decode(b64))).size def render_scale_of(image_b64, window): """实测该图的渲染倍率 = 图像像素宽 / 世界宽。 - PDF 源:世界单位是**点** → 倍率即 px/点,可直接换算纸面字高像素; - 图片源:世界单位是**源像素** → 倍率是「相对原图的缩放」,应恒 ≤ 1(不放大)。 """ w_px, _h_px = png_size(image_b64) w_world = float(window[2]) - float(window[0]) return (w_px / w_world) if w_world > 0 else 0.0 def text_px_of(image_b64, window): """纸面字高(TEXT_HEIGHT_MM)在该图上占多少像素。**仅对 PDF 源有意义**。""" return config.TEXT_HEIGHT_MM * config.PT_PER_MM * render_scale_of(image_b64, window) # ---------------------------------------------------------------- 主流程 def main(): args = parse_args() path = Path(args.path).expanduser().resolve() if not path.is_file(): print("[错误] 文件不存在:%s" % path) return 2 print("=== PDF / 图片离线源验收 ===") print("文件: %s" % path) try: source = sources.detect_source(str(path)) except ValueError as exc: print("[错误] %s" % exc) return 2 if not sources.is_file_source(source): print("[错误] %s 是 CAD 源,请用 CAD 在线流程(不带参数运行 dwg_review)。" % source) return 2 print("来源: %s\n" % source) # ---------------------------------------------------------- 0) 离线 print("0) 离线上下文(不连 CAD)") ctx = ReviewContext.create(need_llm=False, connect_cad=False, source=source, source_path=str(path)) record("ctx.tools is None", ctx.tools is None, "离线源不得持有 CAD 工具表") record("ctx.client is None", ctx.client is None, "离线源不得创建 MCP 客户端") record("无需 CAD 端口", True, "本脚本全程未连接 %s:%d" % (config.HOST, config.PORT)) try: return run_checks(ctx, path, args) finally: sources.close_reader(ctx) def parse_args(): import argparse p = argparse.ArgumentParser(description="PDF / 图片离线源验收") p.add_argument("path", help="PDF 或图片文件路径") p.add_argument("--pages", type=int, default=0, help="只验前 N 页(0=全部,默认前 5 页)") p.add_argument("--dump", default=None, help="把证据图片导出到该目录供目视") p.add_argument("--llm", action="store_true", help="追加真实 LLM 端到端(需 DEEPSEEK_API_KEY)") a = p.parse_args() if a.pages == 0: a.pages = 5 return a def run_checks(ctx, path, args): # ---------------------------------------------------------- 1) 规划 print("\n1) 规划(每页一个图框,保持原始页序)") plan = planning.plan(ctx) frames = plan["frames"] info("drawing_info: %s" % plan["drawing_info"]) record("frames 非空", bool(frames), "共 %d 页" % len(frames)) if not frames: return 1 idx = [f.get("page_index") for f in frames] record("页序保持 0..N-1", idx == list(range(len(frames))), "page_index=%s" % idx) bad = [f for f in frames if not f.get("bbox") or f["bbox"][2] <= 0] record("每页 bbox 有效", not bad, "非法 %d 页" % len(bad)) if frames: info("页 0: bbox=%s 幅面=%s" % (frames[0]["bbox"], frames[0].get("sheet_size"))) # ---------------------------------------------------------- 2) 取证 limit = min(args.pages, len(frames)) source_is_pdf = ctx.source == evidence_model.SOURCE_PDF print("\n2) 取证(前 %d 页)" % limit) dumps = [] stats = {"tiles": 0, "text_pages": 0, "text_items": 0, "min_text_px": None} for i in range(limit): frame = frames[i] ev = evidence_phase.collect(ctx, i + 1, frame) if ev is None: record("页 %d 取证" % i, False, "collect 返回 None") continue page_box = tiling.frame_bbox(frame) win = ev["window"] assert win is not None # 非文本结构化键必须是空信封;`texts` 例外(PDF 的 best-effort 文本层就放这里) struct_ok = all( ev.get(k) == {"total": 0, "items": []} for k in evidence_model.STRUCTURED_SOURCES if k != "texts" ) texts_env = ev.get("texts") or {} struct_ok = struct_ok and isinstance(texts_env, dict) and isinstance( texts_env.get("items"), list ) imgs = evidence_model.evidence_images(ev) labels = [im.get("label") for im in imgs] ok_shape = ( ev["source_type"] == evidence_model.source_type_of(frame) and imgs and labels[0] == "overall" ) record( "页 %d evidence 同形" % i, struct_ok and ok_shape, "结构化=空信封, 图=%s, 文本层=%d 条" % (labels, (ev.get("texts") or {}).get("total", 0)), ) # 每张图:PNG 可解码、世界窗口在图框内、实测渲染倍率 bad_win = [] tile_px, tile_zoom, overall_px = [], [], None for im in imgs: w = im.get("window") if not w or len(w) != 4 or w[0] < -1e-6 or w[1] < -1e-6 or \ w[2] > page_box[2] + 1e-6 or w[3] > page_box[3] + 1e-6: bad_win.append(im.get("label")) continue try: px = png_size(im["b64"]) except Exception as exc: # noqa: BLE001 bad_win.append("%s(解码失败:%s)" % (im.get("label"), exc)) continue scale = render_scale_of(im["b64"], w) tpx = text_px_of(im["b64"], w) # 整体图是「整页缩略」——字读不清属**设计如此**(细节由瓦片负责),不参与达标判定 if im.get("label") == "overall": overall_px = tpx else: tile_px.append(tpx) tile_zoom.append(scale) if max(px) > config.IMAGE_MAX_EDGE + 1: bad_win.append("%s(超长边 %d)" % (im.get("label"), max(px))) if args.dump: dumps.append((i, im.get("label"), im["b64"], px, w, tpx)) record("页 %d 图片世界窗口在图框内" % i, not bad_win, "越界/异常: %s" % (bad_win or "无")) # 分辨率自检 floor = config.LINE_READABLE_PX - 0.5 if tile_px: page_min = min(tile_px) stats["min_text_px"] = ( page_min if stats["min_text_px"] is None else min(stats["min_text_px"], page_min) ) n_text_ok = sum(1 for v in tile_px if v >= config.TEXT_READABLE_PX - 0.5) detail = ("瓦片 %d 张,最差 %.1fpx(下限 %.1f;达文字档 %.1fpx 的 %d 张);" "整体图 %.1fpx" % ( len(tile_px), page_min, floor, config.TEXT_READABLE_PX, n_text_ok, overall_px if overall_px is not None else -1)) if source_is_pdf: record("页 %d 瓦片分辨率达标" % i, page_min >= floor, detail) else: # 图片源不允许放大(IMAGE_MAX_ZOOM=1),倍率必须 ≤1 record( "页 %d 图片瓦片未放大" % i, max(tile_zoom) <= config.IMAGE_MAX_ZOOM + 1e-6, "倍率 %.2f~%.2f(上限 %.2f);%s" % (min(tile_zoom), max(tile_zoom), config.IMAGE_MAX_ZOOM, detail), ) stats["tiles"] += max(0, len(imgs) - 1) if (ev.get("texts") or {}).get("total"): stats["text_pages"] += 1 stats["text_items"] += ev["texts"]["total"] # 旋转自检 check_rotation(ctx, frame, ev, i) info( "瓦片合计 %d 张;有文本层的页 %d;文本片段 %d;%s" % ( stats["tiles"], stats["text_pages"], stats["text_items"], ("最差字高像素 %s" % ("%.1f" % stats["min_text_px"]) if stats["min_text_px"] is not None and source_is_pdf else "(图片源:倍率 ≤1,不放大)" if not source_is_pdf else "n/a"), ) ) if stats["min_text_px"] is not None and source_is_pdf: record( "全局瓦片分辨率达标", stats["min_text_px"] >= config.LINE_READABLE_PX - 0.5, "最差 %.1fpx(下限 %.1f)" % (stats["min_text_px"], config.LINE_READABLE_PX), ) # ---------------------------------------------------------- 5) 导出 if args.dump and dumps: out = Path(args.dump) out.mkdir(parents=True, exist_ok=True) for i, label, b64, px, w, tpx in dumps: name = "p%02d_%s.png" % (i, label) (out / name).write_bytes(base64.b64decode(b64)) info("导出 %s %sx%s 倍率=%.2f 世界=%s" % (name, px[0], px[1], render_scale_of(b64, w), [round(v, 1) for v in w])) record("导出目视包", True, "%d 张 → %s" % (len(dumps), out)) # ---------------------------------------------------------- 6) LLM if args.llm: print("\n6) 真实 LLM 端到端(python -m dwg_review)") if not config.has_api_key(): record("LLM 端到端", True, "跳过:未配置 DEEPSEEK_API_KEY") else: report_dir = Path(args.dump or "out/verify_pdf_source") / "report" env = dict(os.environ, REPORT_DIR=str(report_dir)) cmd = [sys.executable, "-m", "dwg_review", str(path), "--frames", "1"] print(" $ %s" % " ".join(cmd)) proc = subprocess.run(cmd, env=env, capture_output=True, text=True, encoding="utf-8", errors="replace") tail = (proc.stdout or "")[-1500:] print(tail) if proc.stderr: print("[stderr] %s" % proc.stderr[-800:]) mds = sorted(report_dir.glob("review_*.md")) if report_dir.exists() else [] jsons = sorted(report_dir.glob("review_*.json")) if report_dir.exists() else [] record("退出码 0", proc.returncode == 0, "returncode=%s" % proc.returncode) record("报告已落盘", bool(mds and jsons), "%s" % (mds[-1].name if mds else "无")) # ---------------------------------------------------------- 汇总 print("\n=== 汇总 ===") failed = [r for r in RESULTS if not r[1]] for name, _ok, detail in RESULTS: print(" [%s] %-34s %s" % ("PASS" if _ok else "FAIL", name, detail)) print("\n通过 %d / %d" % (len(RESULTS) - len(failed), len(RESULTS))) if failed: print("失败项:%s" % ", ".join(r[0] for r in failed)) return 1 return 0 def _unrotate_point(X, Y, mw, mh, rotation): """`sources.rotate_point` 的逆:世界坐标 → 未旋转坐标。""" r = int(rotation) % 360 if r == 90: return (mw - Y, X) if r == 180: return (mw - X, mh - Y) if r == 270: return (Y, mh - X) return (X, Y) def _unrotate_box(world, mw, mh, rotation): x0, y0, x1, y1 = world pts = [ _unrotate_point(x, y, mw, mh, rotation) for x, y in ((x0, y0), (x0, y1), (x1, y0), (x1, y1)) ] xs = [p[0] for p in pts] ys = [p[1] for p in pts] return (min(xs), min(ys), max(xs), max(ys)) def _render_raw(reader, page_index, box, zoom, dims): """按**指定页尺寸**渲染(`dims` 决定 crop 基准)。 `PdfReader.render` 固定以「旋转后页尺寸」为 crop 基准;做「忽略旋转」对照时 需要用 MediaBox 尺寸作基准,故这里直接调 pdfium。 """ page = reader.doc[page_index] bitmap = page.render(scale=zoom, rotation=0, crop=sources.crop_of(box, dims[0], dims[1])) return bitmap.to_pil() def check_rotation(ctx, frame, ev, page_index): """旋转自检:`/Rotate` 映射必须让「文本矩形」落在真有字的地方。 两条判据(都来自当初的手工探针:rot90 命中率 0.335 vs 其它假设 ≤0.064): a) 文本矩形处的墨迹密度 **明显高于** 整页平均(映射错了就会落进空白); b) 同一文本矩形若**忽略** `/Rotate` 直接当世界坐标,密度应更低(反向对照)。 """ items = (ev.get("texts") or {}).get("items") or [] reader = ctx.source_reader if not items or not isinstance(reader, sources.PdfReader): return page_idx = frame.get("page_index", 0) rw, rh, mw, mh, rot = reader._meta(page_idx) if rot % 360 == 0: return page_box = tiling.frame_bbox(frame) rects = [it["bbox"] for it in items if it.get("bbox") and it["bbox"][2] > it["bbox"][0]] if not rects: return zoom = 4.0 page_img = reader.render(page_box, 2.0, page_idx) page_density = dark_frac(page_img) if page_img is not None else 0.0 hits, wrongs = [], [] for rect in rects[:20]: correct = clip_box(rect, page_box) if correct is None: continue img = reader.render(correct, zoom, page_idx) if img is not None: hits.append(dark_frac(img)) wrong_box = _unrotate_box(rect, mw, mh, rot) img_w = _render_raw(reader, page_idx, wrong_box, zoom, (mw, mh)) if img_w is not None: wrongs.append(dark_frac(img_w)) if not hits: return mean_hit = sum(hits) / len(hits) record( "页 %d 旋转映射命中文本区(/Rotate %d)" % (page_index, rot), mean_hit > page_density * 1.5 and mean_hit > 0.05, "文本区墨迹=%.3f vs 整页平均=%.3f(n=%d)" % (mean_hit, page_density, len(hits)), ) if wrongs: mean_wrong = sum(wrongs) / len(wrongs) record( "页 %d 反向对照(忽略旋转更差)" % page_index, mean_hit > mean_wrong, "正确=%.3f vs 忽略旋转=%.3f" % (mean_hit, mean_wrong), ) if __name__ == "__main__": sys.exit(main())