Files
cad-agent/verify_pdf_source.py
T
gjm 6143bb9ab2 feat(review): PDF/图片离线源适配(C1)—— 无需 CAD 的整条审查链路
支持直接审查 PDF 与位图图纸,全链路离线(不连 CAD、不连 8080 端口)。
渲染在内存内完成,复用既有墨迹热力图 + 自适应瓦片规划。

- 新增 dwg_review/sources.py
  - detect_source / plan_file / collect 统一入口
  - PdfReader:pypdfium2 惰性导入,页序即图框序,处理 /Rotate 与 crop 边距
  - ImageReader:世界单位 = 源像素,snap_box 整数对齐保证窗口与像素严格一致
  - ink_grid:纯 PIL 灰度阈值 → 占用网格(不引入 numpy)
- ReviewContext.create(connect_cad=False):离线时 client/tools 均为 None
- 全链路 ctx.tools is None 守卫:review 只读子循环 / inspect_region / annotator /
  checks 的窗口类检查 / 绘图区最大化
- checklist.build_system_prompt(allow_tools=False, offline=True):离线提示词
- __main__:位置参数 source,按扩展名自动判定源类型并决定是否连 CAD
- config 新增 8 个常量(PDF_MAX_PAGES / 文本最小字数 / 各源缩放上限 / 墨迹阈值等)
- tiling.content_predicate 与 heatmap.cell_box 提升为公开函数

验证:
- pytest 457 passed
- verify_pdf_source.py:PDF 23/23、图片 9/9(不需 CAD),含实测瓦片字高像素与
  旋转映射自检(文本区墨迹 0.311 vs 忽略 /Rotate 反向对照 0.145)
- 真实 LLM 端到端:20 页 HVAC 图纸前 3 页跑通,结论具体可核验且未编造 handle

已知局限:页边线在墨迹网格里会把整页连成单簇,瓦片常退化为整页象限(CAD 侧
靠 drop_oversized 丢图框块,离线侧暂无等价手段),留待后续打磨。
2026-10-16 23:07:23 +08:00

408 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.
# -*- 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 <file>`,检查报告落盘
"""
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())